Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/reference/required-work-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,13 @@ Reader failures preserve the runtime's `error_kind` and `diagnostic_code` so
programs need not parse `reason`. Selector rejections use `request_rejected`
with `context_selection_invalid_pointer`, `context_selection_invalid_index`
or `context_selection_missing_pointer`. Repair the pointer syntax or inspect
the known parent in the same capture, then retry the corrected batch. Other
the known parent in the same capture, then retry the corrected batch. Missing
members and invalid array indexes also return `recovery_command`: it lists the
nearest verified container from the caller's original pointer, using the same
capture digest and page budgets. If traversal reached a scalar, it lists that
scalar's containing object/array instead. This command repeats only the supplied
address prefix, never source values, and starts a new index at offset zero;
it is not continuation of the failed selection or proof of reading its body. Other
runtime failures retain their own classification; startup failures retain
their diagnostic code. These failures do not require restoring a healthy
capture. Missing or damaged capture files still fail closed through the
Expand Down Expand Up @@ -1106,7 +1112,11 @@ pointer 从原始摘要绑定的 capture 批量读取详情。提供已有 `--ca
读取失败保留 runtime 的 `error_kind` 与 `diagnostic_code`,程序无需解析 `reason`。
选择错误使用 `request_rejected`,原因码分别为 `context_selection_invalid_pointer`、
`context_selection_invalid_index` 或 `context_selection_missing_pointer`。
修正路径语法或在同一 capture 中检查已知父节点,再重试修正后的批次。其他 runtime
修正路径语法或在同一 capture 中检查已知父节点,再重试修正后的批次。字段缺失和
数组索引错误还返回 `recovery_command`:用同一 capture 摘要与分页预算列出原始
输入路径中最近的已验证容器;若已走到标量,则列出包含它的对象或数组。命令仅重复
调用方提供的路径前缀,不包含源正文,并从偏移零开始新目录;它不是失败选择的续读,
也不证明读过正文。其他 runtime
故障保留自身分类;启动故障保留原诊断码。这些故障不要求恢复健康的 capture。
文件缺失或损坏仍由原文件/摘要检查拒绝,重新读取不会重新准入。

Expand Down
17 changes: 16 additions & 1 deletion loopx/cli_commands/quota_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,22 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An

depth_options = {"list_depth": list_depth} if list_depth is not None else {}
selection_options = {"initial_read": True} if initial_read else {"select": selectors}
plan = _turn_envelope_result("context_select", context=payload, list_fields=list_fields, **selection_options, **depth_options)
plan = _turn_envelope_result("context_select", context=payload, list_fields=list_fields,
recover_selection_errors=not initial_read, **selection_options, **depth_options)
if rejection := plan.get("rejected_selection"):
# TS alone resolves the nearest container. Reuse the original
# escaped tokens, without parsing diagnostic prose or traversing
# the payload again in Python. A failed batch delivers no body.
pointer = selectors[rejection["argument_index"] - 1]
parent = "/".join(pointer.split("/")[:rejection["parent_token_count"] + 1])
command = context_capture_read_command(path, digest, select=[parent], list_fields=True,
page_bytes=next_page_bytes or size,
max_page_output_bytes=next_page_output_bytes or output_limit)
return {"ok": False, "schema_version": "context_capture_page_v1",
"error_kind": "request_rejected", "diagnostic_code": rejection["code"],
"reason": rejection["message"], "recovery_command": command,
"recovery": "Use recovery_command to --list the nearest verified container in the same capture, "
"then retry the corrected batch. No values were delivered and no guard, admission or spend was performed."}
views: list[dict[str, Any]] = []
for item in plan["selections"]:
selected: Any = payload
Expand Down
36 changes: 32 additions & 4 deletions loopx/control_plane/quota/context_selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import {requireJsonObject} from "../runtime_decode.ts";
import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
import type {JsonObject} from "../effect_program.ts";

// Local selection diagnostics: counts only, never source keys or values.
class SelectionLookupError extends EffectRuntimeRequestError {
readonly argumentIndex: number;
readonly parentTokenCount: number;

constructor(message: string, code: string, argumentIndex: number, parentTokenCount: number) {
super(message, code);
this.argumentIndex = argumentIndex;
this.parentTokenCount = parentTokenCount;
}
}

/** Initial navigation may share a complete parent instead of repeating every
* child address. The selected facts stay exact; unselected children prevent
* sharing. Explicit selectors and discovery keep their original addresses. */
Expand Down Expand Up @@ -40,6 +52,20 @@ function coalescedInitialPointers(context: JsonObject, pointers: string[]): stri
}

export function capturedContextSelection(request: JsonObject, initialPointers: string[] = []): JsonObject {
if (request.recover_selection_errors !== undefined && typeof request.recover_selection_errors !== "boolean")
throw new EffectRuntimeRequestError("recover-selection-errors must be boolean");
try {
return resolveSelection(request, initialPointers);
} catch (error) {
if (!(error instanceof SelectionLookupError) || request.recover_selection_errors !== true) throw error;
// No partial selections survive. The IO adapter can bind a discovery
// command to this verified prefix of the caller's original argument.
return {rejected_selection: {message: error.message, code: error.code,
argument_index: error.argumentIndex, parent_token_count: error.parentTokenCount}};
}
}

function resolveSelection(request: JsonObject, initialPointers: string[]): JsonObject {
const context = requireJsonObject(request.context, "captured context");
if (request.initial_read !== undefined && typeof request.initial_read !== "boolean")
throw new EffectRuntimeRequestError("initial-read must be boolean");
Expand Down Expand Up @@ -70,18 +96,20 @@ export function capturedContextSelection(request: JsonObject, initialPointers: s
const tokens = pointer === "" ? [] : pointer.slice(1).split("/").map(token => token.replace(/~1/g, "/").replace(/~0/g, "~"));
const path: (string | number)[] = [];
let value: unknown = context;
let parentTokenCount = 0;
for (const token of tokens) {
if (value !== null && typeof value === "object") parentTokenCount = path.length;
if (Array.isArray(value)) {
if (!/^(0|[1-9][0-9]*)$/.test(token) || !Number.isSafeInteger(Number(token)) || Number(token) >= value.length)
throw new EffectRuntimeRequestError(`context selection array index is missing or invalid (${argument}, token ${path.length + 1}; positions are 1-based)`,
"context_selection_invalid_index");
throw new SelectionLookupError(`context selection array index is missing or invalid (${argument}, token ${path.length + 1}; positions are 1-based)`,
"context_selection_invalid_index", argumentsByPointer.get(pointer)!, parentTokenCount);
path.push(Number(token));
value = value[Number(token)];
} else if (value !== null && typeof value === "object" && Object.hasOwn(value, token)) {
path.push(token);
value = (value as JsonObject)[token];
} else throw new EffectRuntimeRequestError(`context selection pointer does not exist (${argument}, token ${path.length + 1}; positions are 1-based)`,
"context_selection_missing_pointer");
} else throw new SelectionLookupError(`context selection pointer does not exist (${argument}, token ${path.length + 1}; positions are 1-based)`,
"context_selection_missing_pointer", argumentsByPointer.get(pointer)!, parentTokenCount);
}
// Return the verified address, not a JS-number-coerced copy of the value.
// The file adapter retains original JSON values and owns byte serialization.
Expand Down
43 changes: 42 additions & 1 deletion tests/control_plane/test_context_capture_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ def test_cli_repairs_missing_selection_using_typed_error_and_same_capture(tmp_pa
assert "--list" in failure["recovery"] and "Restore" not in failure["recovery"]
# Branch on the typed cause, not wording in reason; discover and retry
# against the original capture without another guard or admission.
code, index = _run_cli(registry, runtime, *command, "--select", "/rows/0", "--list", cwd=tmp_path)
code, index = _run_cli(registry, runtime, *shlex.split(failure["recovery_command"])[3:], cwd=tmp_path)
assert code == 0 and index["ok"] and index["eof"], index
fields = json.loads(index["content"])["selections"][0]["fields"]
pointer = next(field["pointer"] for field in fields if field["pointer"] == "/rows/0/tail")
Expand Down Expand Up @@ -451,3 +451,44 @@ def unexpected(*_args, **_kwargs):
assert result["ok"] is False and "content" not in result and "next_command" not in result
assert "diagnostic_code" not in result and "error_kind" not in result
assert "Restore the original digest-bound capture" in result["recovery"]


@pytest.mark.parametrize("reference", [False, True])
@pytest.mark.parametrize("mode", ["normal", "replan"])
@pytest.mark.parametrize("pointer,parent", [
("/interaction_contract/cli_channel/settlement_plan/absent", "/interaction_contract/cli_channel"),
("/sources/4/content", "/sources"),
("/a~1b/~0/child", "/a~1b"),
("/missing/child", ""),
])
def test_cli_recovery_command_uses_current_capture_and_nearest_container(tmp_path, mode, pointer, parent, reference):
original = {"interaction_contract": {"cli_channel": {"settlement_plan": None} if mode == "normal"
else {"settlement_read_ref": "opaque-reference"}}, "sources": [], "a/b": {"~": False},
"unrelated": "private-value" * 1000}
path = tmp_path / "capture with ' quote.json"
raw = json.dumps(original).encode()
path.write_bytes(raw)
registry, runtime = tmp_path / "absent.json", tmp_path / "absent-runtime"
command = ["quota", "context-read", "--capture-file", str(path),
"--capture-sha256", hashlib.sha256(raw).hexdigest(), "--select", "/unrelated", "--select", pointer]
if reference:
from loopx.control_plane.turn_driver.decision_capture import write_context_read_reference
reader = write_context_read_reference(path, hashlib.sha256(raw).hexdigest())
command = ["quota", "context-read", "--reference-file", str(reader),
"--select", "/unrelated", "--select", pointer]
code, failure = _run_cli(registry, runtime, *command, cwd=tmp_path)
assert code != 0 and not failure["ok"] and "content" not in failure
assert "private-value" not in json.dumps(failure)
recovery = shlex.split(failure["recovery_command"])
assert recovery[recovery.index("--select") + 1] == parent
assert "--list" in recovery and recovery[recovery.index("--offset") + 1] == "0"
code, index = _run_cli(registry, runtime, *recovery[3:], cwd=tmp_path)
assert code == 0 and index["ok"] and index["eof"]
assert json.loads(index["content"])["selections"][0]["pointer"] == parent
assert "private-value" not in index["content"]
assert capture_page_range(index, raw=raw, digest=index["sha256"]) is None
path.write_bytes(raw + b" ")
code, changed = _run_cli(registry, runtime, *recovery[3:], cwd=tmp_path)
assert code != 0 and not changed["ok"] and "content" not in changed
assert "recovery_command" not in changed
assert not registry.exists() and not runtime.exists()
27 changes: 27 additions & 0 deletions tests/control_plane_ts/context_selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,30 @@ test("unknown decision schemas and ordinary selections keep generic discovery",
for (const options of [{select: [""]}, {list_fields: true}, {list_depth: 2}])
assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context, initial_read: true, ...options}), /cannot be combined/);
});


test("recoverable lookups return only caller positions and nearest container counts, never partial values", () => {
const context = {z: "private-value", "a/b": {"~": [{leaf: null}]}};
for (const [pointer, parentCount] of [
["/a~1b/~0/8", 2], ["/a~1b/~0/01", 2], ["/a~1b/~0/0/absent", 3],
["/a~1b/~0/0/leaf/deeper", 3], ["/z/deeper", 0], ["/absent/deeper", 0],
] as const) {
const result = evaluateTurnEnvelope({operation: "context_select", context,
select: ["/z", "/z", pointer], recover_selection_errors: true});
assert.equal(result.selections, undefined);
assert.deepEqual(Object.keys(result), ["rejected_selection"]);
const failure = result.rejected_selection as Record<string, unknown>;
assert.equal(failure.argument_index, 3);
assert.equal(failure.parent_token_count, parentCount);
assert.ok(!JSON.stringify(result).includes("private-value"));
assert.ok(!JSON.stringify(result).includes("a~1b"));
}
for (const options of [{recover_selection_errors: false}, {}])
assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context, select: ["/missing"], ...options}));
assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context,
select: ["/~2"], recover_selection_errors: true}), /RFC 6901/);
assert.throws(() => evaluateTurnEnvelope({operation: "context_select", context,
select: ["/z"], recover_selection_errors: "true"}), /must be boolean/);
assert.deepEqual(evaluateTurnEnvelope({operation: "context_select", context,
select: ["/z"], recover_selection_errors: true}), select(context, ["/z"]));
});
Loading