@sema-agent/core 5.21.0 → 5.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/dist/agents/send-message-tool.js +6 -3
- package/dist/agents/subagent.d.ts +6 -0
- package/dist/agents/subagent.js +45 -4
- package/dist/brain/errors.d.ts +20 -0
- package/dist/brain/errors.js +40 -0
- package/dist/brain/retry.d.ts +16 -2
- package/dist/brain/retry.js +3 -2
- package/dist/brain/status-sink.d.ts +9 -2
- package/dist/brain/stream-engine.d.ts +22 -0
- package/dist/brain/stream-engine.js +41 -10
- package/dist/core/ask-class.d.ts +48 -0
- package/dist/core/ask-class.js +33 -0
- package/dist/core/checkpoint-store.d.ts +103 -10
- package/dist/core/checkpoint-store.js +3 -1
- package/dist/core/governance-codes.d.ts +38 -0
- package/dist/core/governance-codes.js +11 -0
- package/dist/core/hooks.d.ts +39 -0
- package/dist/core/hooks.js +26 -2
- package/dist/core/locked-config.d.ts +7 -1
- package/dist/core/locked-config.js +2 -1
- package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
- package/dist/core/memory-engine/delegation-provenance.js +26 -0
- package/dist/core/memory-engine/engine.d.ts +67 -1
- package/dist/core/memory-engine/engine.js +270 -12
- package/dist/core/memory-engine/header-hints.d.ts +30 -0
- package/dist/core/memory-engine/header-hints.js +41 -0
- package/dist/core/memory-engine/index.d.ts +3 -2
- package/dist/core/memory-engine/index.js +3 -2
- package/dist/core/memory-engine/layout.d.ts +166 -0
- package/dist/core/memory-engine/layout.js +399 -0
- package/dist/core/memory-engine/tools.d.ts +30 -0
- package/dist/core/memory-engine/tools.js +108 -17
- package/dist/core/permission-rule-consent.d.ts +25 -9
- package/dist/core/permission-rule-consent.js +91 -20
- package/dist/core/permission-rule-model.d.ts +9 -1
- package/dist/core/permission-rule-model.js +2 -2
- package/dist/core/permission-rule-org.d.ts +161 -0
- package/dist/core/permission-rule-org.js +211 -0
- package/dist/core/permission-rule-store.d.ts +249 -6
- package/dist/core/permission-rule-store.js +313 -3
- package/dist/core/permission-rule-sync.d.ts +131 -0
- package/dist/core/permission-rule-sync.js +314 -0
- package/dist/core/runner/prepare-memory.js +35 -8
- package/dist/core/runner/prepare-task.d.ts +54 -1
- package/dist/core/runner/prepare-task.js +246 -27
- package/dist/core/runner/runtask.js +147 -6
- package/dist/core/shared-memory/contract.js +19 -4
- package/dist/core/shared-memory/normalize.d.ts +3 -1
- package/dist/core/shared-memory/tools.js +73 -17
- package/dist/core/shared-memory/types.d.ts +27 -1
- package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
- package/dist/core/task-notification.d.ts +5 -2
- package/dist/core/task-registry-agent.d.ts +1 -1
- package/dist/core/task-registry-agent.js +6 -2
- package/dist/core/task-registry-shared.d.ts +9 -2
- package/dist/core/task-registry.d.ts +9 -3
- package/dist/core/task-registry.js +2 -0
- package/dist/core/tool-policy.d.ts +120 -2
- package/dist/core/tool-policy.js +116 -6
- package/dist/core/trace.d.ts +32 -1
- package/dist/core/types.d.ts +56 -3
- package/dist/index.d.ts +12 -7
- package/dist/index.js +10 -5
- package/dist/stores/file/checkpoint-store.d.ts +4 -0
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
- package/dist/stores/file/permission-rule-adopt.js +95 -0
- package/dist/stores/file/permission-rule-store.d.ts +80 -2
- package/dist/stores/file/permission-rule-store.js +189 -46
- package/dist/tools/fs/fs-search-tools.js +0 -1
- package/package.json +1 -1
|
@@ -32,13 +32,13 @@ import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
|
|
|
32
32
|
import { hasVerifiableStructureSignal } from "./grounding-signal.js";
|
|
33
33
|
import { hasDestroy, isIsolated } from "../remote-env.js";
|
|
34
34
|
import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
|
|
35
|
-
import { formatHookFeedback } from "../hooks.js";
|
|
35
|
+
import { cloneObserverInput, formatHookFeedback } from "../hooks.js";
|
|
36
36
|
import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
|
|
37
37
|
import { delimitUntrusted, inlineUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
|
|
38
38
|
import { reconcileInterruptedSession } from "../session-reconcile.js";
|
|
39
39
|
import { RunnerSharedToolResultStore } from "../tool-result-store.js";
|
|
40
40
|
import { formatDiagnosticsBlock } from "../lsp-diagnostics.js";
|
|
41
|
-
import { isApprovalSettledBy, refuseOutOfContractDecision, toolPolicyNameSets } from "../tool-policy.js";
|
|
41
|
+
import { checkToolPolicyProjection, constraintChainDigest, constraintChainEntryOf, isApprovalSettledBy, refuseOutOfContractDecision, toolPolicyNameSets } from "../tool-policy.js";
|
|
42
42
|
import { defaultTaskRegistry } from "../task-registry.js";
|
|
43
43
|
import { discloseDroppedPending, isDelegatedAgentTerminal, PendingSessionNotifications, renderTaskNotificationXml, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
|
|
44
44
|
import { ToolDetachHub } from "../tool-detach.js";
|
|
@@ -2324,6 +2324,7 @@ export class Runner {
|
|
|
2324
2324
|
...(s.retryInMs !== undefined ? { retryInMs: s.retryInMs } : {}),
|
|
2325
2325
|
...(s.attempt !== undefined ? { attempt: s.attempt } : {}),
|
|
2326
2326
|
...(s.maxRetries !== undefined ? { maxRetries: s.maxRetries } : {}),
|
|
2327
|
+
...(s.errClass !== undefined ? { errClass: s.errClass } : {}),
|
|
2327
2328
|
...ident(),
|
|
2328
2329
|
});
|
|
2329
2330
|
};
|
|
@@ -2341,7 +2342,16 @@ export class Runner {
|
|
|
2341
2342
|
: t.kind === "breaker"
|
|
2342
2343
|
? { kind: "breaker.transition", version: 1, taskId: rs.telemetry.taskId, key: t.key, phase: t.phase, failures: t.failures, ts: Date.now() }
|
|
2343
2344
|
: t.kind === "retry"
|
|
2344
|
-
? {
|
|
2345
|
+
? {
|
|
2346
|
+
kind: "brain.retry",
|
|
2347
|
+
version: 1,
|
|
2348
|
+
taskId: rs.telemetry.taskId,
|
|
2349
|
+
attempt: t.attempt,
|
|
2350
|
+
phase: t.phase,
|
|
2351
|
+
...(t.errClass !== undefined ? { errClass: t.errClass } : {}),
|
|
2352
|
+
...(t.nextDelayMs !== undefined ? { nextDelayMs: t.nextDelayMs } : {}),
|
|
2353
|
+
ts: Date.now(),
|
|
2354
|
+
}
|
|
2345
2355
|
: { kind: "vision.placeholder", version: 1, taskId: rs.telemetry.taskId, count: t.count, ts: Date.now() });
|
|
2346
2356
|
};
|
|
2347
2357
|
const withBrainSinks = (fn) => runWithStatusSink(statusEmit, () => runWithBrainTelemetry(telemetryEmit, fn));
|
|
@@ -3719,6 +3729,128 @@ export class Runner {
|
|
|
3719
3729
|
"re-supply would run the resumed leg under a different ancestor chain than it suspended with; " +
|
|
3720
3730
|
"rejected pre-CAS (the checkpoint stays pending) — re-resume with the full original chain");
|
|
3721
3731
|
}
|
|
3732
|
+
const expectedDigest = cp.state.inheritedGate.constraintDigest;
|
|
3733
|
+
if (expectedDigest !== undefined) {
|
|
3734
|
+
const persistedChain = cp.state.inheritedGate.constraintChain;
|
|
3735
|
+
if (persistedChain === undefined || constraintChainDigest(persistedChain) !== expectedDigest) {
|
|
3736
|
+
throw new CheckpointError("resume.parent_constraint_mismatch", "the checkpoint's OWN recorded constraint chain does not match its recorded digest " +
|
|
3737
|
+
"(corrupt / inconsistently-minted row) — the frozen deny data the resume would execute is not the data the " +
|
|
3738
|
+
"digest authenticates; rejected pre-CAS (the checkpoint stays pending)");
|
|
3739
|
+
}
|
|
3740
|
+
const suppliedDigest = constraintChainDigest((internals?.inheritedGate?.parentConstraints ?? []).map((pc) => constraintChainEntryOf(pc.policy, { ...(pc.autoMode !== undefined ? { autoModeArmed: true } : {}), ...(pc.durableMandate === true ? { durableMandate: true } : {}), ...(pc.contentMandate === true ? { contentMandate: true } : {}) })));
|
|
3741
|
+
if (suppliedDigest !== expectedDigest) {
|
|
3742
|
+
throw new CheckpointError("resume.parent_constraint_mismatch", "the re-supplied parent-constraint chain's projection content does not match what the checkpoint recorded " +
|
|
3743
|
+
`(recorded ${expectedDigest}, re-supplied ${suppliedDigest}) — a same-shape chain with different frozen ` +
|
|
3744
|
+
"deny data would run the resumed leg under a different ancestor gate than it suspended with; " +
|
|
3745
|
+
"rejected pre-CAS (the checkpoint stays pending) — re-resume with the original chain's policies");
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
const resumeInternals = internals?.inheritedGate?.parentConstraints !== undefined
|
|
3750
|
+
? {
|
|
3751
|
+
...internals,
|
|
3752
|
+
inheritedGate: {
|
|
3753
|
+
...internals.inheritedGate,
|
|
3754
|
+
parentConstraints: Object.freeze(internals.inheritedGate.parentConstraints.map((e) => ({ ...e }))),
|
|
3755
|
+
},
|
|
3756
|
+
}
|
|
3757
|
+
: internals;
|
|
3758
|
+
if (this.deps.lockedConfig?.keys?.includes("toolPolicy") === true && taskConfig.basePolicyForResumeEdit !== undefined) {
|
|
3759
|
+
throw new CheckpointError("checkpoint.invalid_outcome", "TaskSpec.basePolicyForResumeEdit is administratively locked by this deployment (locked key \"toolPolicy\") — a task-supplied " +
|
|
3760
|
+
"resume-edit policy is refused pre-CAS (the checkpoint stays pending); remove the field or change the deployment's lock configuration");
|
|
3761
|
+
}
|
|
3762
|
+
if (plainPolicyOutcome !== undefined &&
|
|
3763
|
+
plainPolicyOutcome.decision === "allow" &&
|
|
3764
|
+
plainPolicyOutcome.updatedInput !== undefined &&
|
|
3765
|
+
cp.pendingAction.kind === "tool_approval" &&
|
|
3766
|
+
cp.pendingAction.toolName !== ASK_USER_QUESTION_TOOL_NAME) {
|
|
3767
|
+
const editedArgs = plainPolicyOutcome.updatedInput;
|
|
3768
|
+
const editBudget = {
|
|
3769
|
+
resourceRemainingMicroUsd: remainingBudgetMicroUsd(cp.resourceLedger),
|
|
3770
|
+
resourceSpentMicroUsd: cp.resourceLedger?.spentMicroUsd ?? 0,
|
|
3771
|
+
suspendCount: cp.suspendCount ?? 0,
|
|
3772
|
+
};
|
|
3773
|
+
const editedReq = {
|
|
3774
|
+
toolName: cp.pendingAction.toolName,
|
|
3775
|
+
args: editedArgs,
|
|
3776
|
+
toolCallId: cp.pendingAction.toolCallId,
|
|
3777
|
+
budget: editBudget,
|
|
3778
|
+
...(cp.state.handsCwd !== undefined ? { cwd: cp.state.handsCwd } : {}),
|
|
3779
|
+
};
|
|
3780
|
+
const editSignal = taskConfig.signal;
|
|
3781
|
+
const raceEditAbort = async (p) => {
|
|
3782
|
+
if (editSignal === undefined)
|
|
3783
|
+
return p;
|
|
3784
|
+
if (editSignal.aborted) {
|
|
3785
|
+
throw new CheckpointError("checkpoint.resume_aborted", "the resume was aborted while re-adjudicating the approver's edit — the checkpoint stays pending and is resumable with a live signal");
|
|
3786
|
+
}
|
|
3787
|
+
return new Promise((resolve, reject) => {
|
|
3788
|
+
const onAbort = () => reject(new CheckpointError("checkpoint.resume_aborted", "the resume was aborted while re-adjudicating the approver's edit — the checkpoint stays pending and is resumable with a live signal"));
|
|
3789
|
+
editSignal.addEventListener("abort", onAbort, { once: true });
|
|
3790
|
+
p.then((v) => { editSignal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { editSignal.removeEventListener("abort", onAbort); reject(e); });
|
|
3791
|
+
});
|
|
3792
|
+
};
|
|
3793
|
+
const ig = cp.state.inheritedGate;
|
|
3794
|
+
if (ig?.requiresParentConstraint === true) {
|
|
3795
|
+
const chain = ig.constraintChain;
|
|
3796
|
+
const unprojectable = chain === undefined
|
|
3797
|
+
? "the checkpoint predates the frozen projection chain (no constraintChain recorded)"
|
|
3798
|
+
: chain.some((e) => "opaque" in e)
|
|
3799
|
+
? "the inherited chain carries an OPAQUE layer (no projection exported)"
|
|
3800
|
+
: chain.some((e) => !("opaque" in e) && e.requiresLiveRemainder)
|
|
3801
|
+
? "an inherited chain layer declares a live remainder beyond its projection"
|
|
3802
|
+
: undefined;
|
|
3803
|
+
if (unprojectable !== undefined) {
|
|
3804
|
+
throw new CheckpointError("resume.constraint_unprojectable", `the approver EDITED this call's input, but the inherited ancestor chain cannot re-adjudicate the edit statically: ${unprojectable}. ` +
|
|
3805
|
+
"Refusing the edited resume pre-CAS (the checkpoint stays pending and remains decidable) — approve the shown input as-is, deny it, " +
|
|
3806
|
+
"or re-issue the edited action as a fresh call so the full live chain adjudicates it");
|
|
3807
|
+
}
|
|
3808
|
+
for (const entry of chain) {
|
|
3809
|
+
const denied = checkToolPolicyProjection(entry, editedReq);
|
|
3810
|
+
if (denied !== undefined) {
|
|
3811
|
+
throw new CheckpointError("resume.constraint_rejected", `the approver EDITED this call's input and a FROZEN inherited ancestor constraint denies the edited args${denied.message ? `: ${denied.message}` : ""}. ` +
|
|
3812
|
+
"Refused pre-CAS (fresh-redecision semantics: the checkpoint stays pending and the same token remains decidable — approve as shown, deny, or edit differently)");
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
const toolPolicyLocked = this.deps.lockedConfig?.keys?.includes("toolPolicy") === true;
|
|
3817
|
+
const editBasePolicy = toolPolicyLocked
|
|
3818
|
+
? (this.deps.basePolicyForResumeEdit ?? this.deps.toolPolicy)
|
|
3819
|
+
: (taskConfig.basePolicyForResumeEdit ?? this.deps.basePolicyForResumeEdit ?? taskConfig.toolPolicy ?? this.deps.toolPolicy);
|
|
3820
|
+
if (editBasePolicy !== undefined) {
|
|
3821
|
+
let rechecked;
|
|
3822
|
+
try {
|
|
3823
|
+
rechecked = refuseOutOfContractDecision(await raceEditAbort(Promise.resolve(editBasePolicy.check(editedReq, editSignal))));
|
|
3824
|
+
}
|
|
3825
|
+
catch (err) {
|
|
3826
|
+
if (err instanceof CheckpointError)
|
|
3827
|
+
throw err;
|
|
3828
|
+
rechecked = { action: "deny", message: `the resume-edit policy re-check errored: ${err instanceof Error ? err.message : String(err)}` };
|
|
3829
|
+
}
|
|
3830
|
+
if (rechecked.action === "deny") {
|
|
3831
|
+
throw new CheckpointError("resume.constraint_rejected", `the approver EDITED this call's input and the deployment's resume-edit policy denies the edited args${rechecked.message ? `: ${rechecked.message}` : ""}. ` +
|
|
3832
|
+
"Refused pre-CAS (the checkpoint stays pending and remains decidable)");
|
|
3833
|
+
}
|
|
3834
|
+
if (rechecked.updatedInput !== undefined && !deepJsonEqual(rechecked.updatedInput, editedArgs)) {
|
|
3835
|
+
throw new CheckpointError("resume.constraint_rejected", `the deployment's resume-edit policy REWROTE the approver's edited args to a different value than was approved — the durable resume cannot execute either the un-adjudicated edit or an unapproved rewrite; ` +
|
|
3836
|
+
`refused pre-CAS (the checkpoint stays pending); re-submit the edited action so the rewrite is adjudicated and approved synchronously`);
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
const resumeHooks = taskConfig.hooks ?? this.deps.hooks;
|
|
3840
|
+
if (resumeHooks?.preToolUse !== undefined && resumeHooks.preToolUseObservational === true) {
|
|
3841
|
+
try {
|
|
3842
|
+
await raceEditAbort(Promise.resolve(resumeHooks.preToolUse(cp.pendingAction.toolName, cloneObserverInput(editedArgs), {
|
|
3843
|
+
toolCallId: cp.pendingAction.toolCallId,
|
|
3844
|
+
toolName: cp.pendingAction.toolName,
|
|
3845
|
+
})));
|
|
3846
|
+
}
|
|
3847
|
+
catch (err) {
|
|
3848
|
+
if (err instanceof CheckpointError)
|
|
3849
|
+
throw err;
|
|
3850
|
+
throw new CheckpointError("resume.constraint_rejected", `the declared-observational PreToolUse face threw while observing the approver's edited args (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
3851
|
+
"refused fail-closed pre-CAS (the checkpoint stays pending and remains decidable)");
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3722
3854
|
}
|
|
3723
3855
|
const outcomeForStore = plainPolicyOutcome ?? plainReviewOutcome ?? plainParkOutcome;
|
|
3724
3856
|
if (outcomeForStore === undefined) {
|
|
@@ -3778,7 +3910,7 @@ export class Runner {
|
|
|
3778
3910
|
? new CheckpointError("checkpoint.reopen_failed", "the resume was aborted before the approved action could run AND the store refused to reopen the checkpoint — the approval is terminally consumed and the suspended work was not executed; a retry needs a fresh approval")
|
|
3779
3911
|
: new CheckpointError("checkpoint.reopen_failed", "the resume was aborted before the approved action could run and the reopen attempt FAILED IN FLIGHT — the checkpoint's state is unprovable from here: it may already be pending again. Re-read it before deciding; do NOT issue a fresh approval on the assumption the old one is dead (the approved action did NOT run either way)");
|
|
3780
3912
|
}
|
|
3781
|
-
return this.runTaskStream(spec, { cp, outcome: outcomeForStore, onEnvRestoreFailed, ...(wakeMessage !== undefined ? { wakeMessage } : {}) },
|
|
3913
|
+
return this.runTaskStream(spec, { cp, outcome: outcomeForStore, onEnvRestoreFailed, ...(wakeMessage !== undefined ? { wakeMessage } : {}) }, resumeInternals);
|
|
3782
3914
|
}
|
|
3783
3915
|
async applyResumeDecision(prepared, resume, emit, emitCommitted, onResolvedToolSuccess, onExecuteStart) {
|
|
3784
3916
|
const { pendingAction } = resume.cp;
|
|
@@ -3853,15 +3985,23 @@ export class Runner {
|
|
|
3853
3985
|
if (outcome.decision !== "allow") {
|
|
3854
3986
|
throw new CheckpointError("checkpoint.invalid_outcome", `pending action reached the resolver with decision "${String(outcome.decision)}" — only "allow" executes and only "deny" injects a denial; refusing to execute`);
|
|
3855
3987
|
}
|
|
3988
|
+
const beltBudget = {
|
|
3989
|
+
resourceRemainingMicroUsd: remainingBudgetMicroUsd(prepared.resourceLedger),
|
|
3990
|
+
resourceSpentMicroUsd: prepared.resourceLedger?.spentMicroUsd ?? 0,
|
|
3991
|
+
suspendCount: resume.cp.suspendCount ?? 0,
|
|
3992
|
+
};
|
|
3856
3993
|
if (outcome.decision === "allow" && outcome.updatedInput !== undefined && prepared.basePolicyForResumeEdit) {
|
|
3857
3994
|
const rechecked = refuseOutOfContractDecision(await prepared.basePolicyForResumeEdit.check({
|
|
3858
3995
|
toolName: pendingAction.toolName,
|
|
3859
3996
|
args: resolvedArgs,
|
|
3860
3997
|
toolCallId: pendingAction.toolCallId,
|
|
3998
|
+
budget: beltBudget,
|
|
3861
3999
|
...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
|
|
3862
4000
|
}, prepared.abortController.signal));
|
|
3863
|
-
|
|
3864
|
-
|
|
4001
|
+
const beltUnsafe = rechecked.action === "deny" ||
|
|
4002
|
+
(rechecked.updatedInput !== undefined && !deepJsonEqual(rechecked.updatedInput, resolvedArgs));
|
|
4003
|
+
if (beltUnsafe) {
|
|
4004
|
+
const editedDenial = formatHookFeedback(`The approver EDITED this call's input; the edited call is refused by the deployment's tool policy and was not executed${rechecked.action === "deny" && rechecked.message ? `: ${rechecked.message}` : " (the policy rewrote the edit to a value that was not approved)"}.`);
|
|
3865
4005
|
emitEnd(true, { content: editedDenial });
|
|
3866
4006
|
const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, editedDenial, true));
|
|
3867
4007
|
emitCommitted(eid, "toolResult", pendingAction.toolCallId);
|
|
@@ -3873,6 +4013,7 @@ export class Runner {
|
|
|
3873
4013
|
toolName: pendingAction.toolName,
|
|
3874
4014
|
args: resolvedArgs,
|
|
3875
4015
|
toolCallId: pendingAction.toolCallId,
|
|
4016
|
+
budget: beltBudget,
|
|
3876
4017
|
...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
|
|
3877
4018
|
}, prepared.abortController.signal));
|
|
3878
4019
|
if (narrowed.action === "deny") {
|
|
@@ -31,6 +31,21 @@ async function connectedStores(provider) {
|
|
|
31
31
|
}
|
|
32
32
|
return [...snapshot.stores];
|
|
33
33
|
}
|
|
34
|
+
async function walkList(reader, prefix) {
|
|
35
|
+
const out = [];
|
|
36
|
+
let cursor;
|
|
37
|
+
for (let round = 0; round < 64; round++) {
|
|
38
|
+
const rows = await reader.list(prefix, { ...(cursor !== undefined ? { cursor } : {}), limit: 50 });
|
|
39
|
+
if (Array.isArray(rows))
|
|
40
|
+
return [...out, ...rows];
|
|
41
|
+
out.push(...rows.entries);
|
|
42
|
+
if (rows.nextCursor === undefined)
|
|
43
|
+
return out;
|
|
44
|
+
assert.ok(cursor === undefined || rows.nextCursor > cursor, `paged list nextCursor must strictly advance (got ${JSON.stringify(rows.nextCursor)} after ${JSON.stringify(cursor)})`);
|
|
45
|
+
cursor = rows.nextCursor;
|
|
46
|
+
}
|
|
47
|
+
return assert.fail("paged list did not terminate within 64 windows — nextCursor must eventually be absent");
|
|
48
|
+
}
|
|
34
49
|
function storeById(stores, id) {
|
|
35
50
|
const found = stores.find((s) => s.info.id === id);
|
|
36
51
|
if (!found)
|
|
@@ -93,7 +108,7 @@ export async function sharedMemoryStoreContract(hooks) {
|
|
|
93
108
|
const stores = await connectedStores(await hooks.make(fixture));
|
|
94
109
|
const alpha = storeById(stores, "alpha");
|
|
95
110
|
const normalizedPrefix = "/a";
|
|
96
|
-
const rows = await alpha.reader
|
|
111
|
+
const rows = await walkList(alpha.reader, listPrefixArgument(normalizedPrefix));
|
|
97
112
|
const listed = new Set(rows.map((r) => r.path));
|
|
98
113
|
const expected = fixture.stores[0].documents.filter((d) => matchesPathPrefix(d.path, normalizedPrefix));
|
|
99
114
|
assert.ok(expected.length > 0, "the fixture must actually place documents under the probed prefix");
|
|
@@ -105,7 +120,7 @@ export async function sharedMemoryStoreContract(hooks) {
|
|
|
105
120
|
const fixture = twoStoreFixture();
|
|
106
121
|
const stores = await connectedStores(await hooks.make(fixture));
|
|
107
122
|
const alpha = storeById(stores, "alpha");
|
|
108
|
-
const listed = new Set((await alpha.reader
|
|
123
|
+
const listed = new Set((await walkList(alpha.reader, undefined)).map((r) => r.path));
|
|
109
124
|
for (const doc of fixture.stores[0].documents) {
|
|
110
125
|
assert.ok(listed.has(doc.path), `list(undefined) omitted "${doc.path}"`);
|
|
111
126
|
}
|
|
@@ -122,10 +137,10 @@ export async function sharedMemoryStoreContract(hooks) {
|
|
|
122
137
|
const stores = await connectedStores(await hooks.make(twoStoreFixture()));
|
|
123
138
|
const alpha = storeById(stores, "alpha");
|
|
124
139
|
const beta = storeById(stores, "beta");
|
|
125
|
-
const alphaPaths = new Set((await alpha.reader
|
|
140
|
+
const alphaPaths = new Set((await walkList(alpha.reader, undefined)).map((r) => r.path));
|
|
126
141
|
assert.ok(!alphaPaths.has("/b/only.md"), "store alpha listed a document that belongs to store beta");
|
|
127
142
|
assert.strictEqual(await alpha.reader.read("/b/only.md", {}), null, "store alpha read a document that belongs to store beta");
|
|
128
|
-
const betaPaths = new Set((await beta.reader
|
|
143
|
+
const betaPaths = new Set((await walkList(beta.reader, undefined)).map((r) => r.path));
|
|
129
144
|
assert.ok(!betaPaths.has("/a/one.md"), "store beta listed a document that belongs to store alpha");
|
|
130
145
|
assert.strictEqual(await beta.reader.read("/a/one.md", {}), null, "store beta read a document that belongs to store alpha");
|
|
131
146
|
});
|
|
@@ -102,7 +102,9 @@ export interface NormalizedStore {
|
|
|
102
102
|
info: SharedMemoryStoreInfo;
|
|
103
103
|
list: (prefix: string | undefined, opts: {
|
|
104
104
|
signal?: AbortSignal;
|
|
105
|
-
|
|
105
|
+
cursor?: string;
|
|
106
|
+
limit?: number;
|
|
107
|
+
}) => Promise<SharedMemoryDocumentEntry[] | import("./types.js").SharedMemoryPagedList>;
|
|
106
108
|
read: (path: string, opts: {
|
|
107
109
|
signal?: AbortSignal;
|
|
108
110
|
}) => Promise<{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { errorResult } from "../tools.js";
|
|
3
3
|
import { CONNECTING_MESSAGE, GENERIC_FAILURE_MESSAGE, NO_STORES_MESSAGE, SHARED_STORE_FRAME, UNAVAILABLE_MESSAGE, datePrefixOf, echoUntrusted, foldReaderError, listPrefixArgument, normalizeEntries, normalizeMemoryPath, normalizeReadValue, normalizeSnapshot, pageDocuments, relativePathRefusal, sanitizeSharedContent, validateDocumentPath, validatePathPrefix, } from "./normalize.js";
|
|
4
|
-
import { SHARED_MEMORY_LIST_TOOL_NAME, SHARED_MEMORY_READ_CAP_BYTES, SHARED_MEMORY_READ_TOOL_NAME, } from "./types.js";
|
|
4
|
+
import { SHARED_MEMORY_LIST_PAGE_SIZE, SHARED_MEMORY_LIST_TOOL_NAME, SHARED_MEMORY_READ_CAP_BYTES, SHARED_MEMORY_READ_TOOL_NAME, } from "./types.js";
|
|
5
5
|
const LIST_HINT = "List shared memory stores and their documents — check here before telling the user you do not have something.";
|
|
6
6
|
const LIST_DESCRIPTION = [
|
|
7
7
|
LIST_HINT,
|
|
@@ -58,12 +58,14 @@ function renderEntryLine(entry) {
|
|
|
58
58
|
const path = JSON.stringify(entry.path);
|
|
59
59
|
return meta === "" ? path : `${path} (${meta})`;
|
|
60
60
|
}
|
|
61
|
-
function renderDocumentPage(page, remaining) {
|
|
61
|
+
function renderDocumentPage(page, remaining, providerHasMore = false) {
|
|
62
62
|
if (page.length === 0)
|
|
63
63
|
return "(empty)";
|
|
64
64
|
const footer = remaining > 0
|
|
65
65
|
? `\n… ${remaining} more — call again with cursor=${JSON.stringify(page.at(-1)?.path ?? "")} to continue.`
|
|
66
|
-
:
|
|
66
|
+
: providerHasMore
|
|
67
|
+
? `\n… more — call again with cursor=${JSON.stringify(page.at(-1)?.path ?? "")} to continue.`
|
|
68
|
+
: "";
|
|
67
69
|
return `${SHARED_STORE_FRAME}\n\n${page.map(renderEntryLine).join("\n")}${footer}`;
|
|
68
70
|
}
|
|
69
71
|
function renderDocument(content, updatedAt) {
|
|
@@ -173,31 +175,85 @@ export function createSharedMemoryTools(opts) {
|
|
|
173
175
|
if (refusal)
|
|
174
176
|
return refusedList(refusal, "refused", resolution.diagnostics, store);
|
|
175
177
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
178
|
+
const collected = [];
|
|
179
|
+
let droppedEntries = 0;
|
|
180
|
+
let providerHasMore = false;
|
|
181
|
+
let providerCursor;
|
|
182
|
+
let windowCursor = cursor;
|
|
183
|
+
const MAX_WINDOW_ROUNDS = 8;
|
|
184
|
+
for (let round = 0;; round++) {
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = await resolution.store.list(normalizedPrefix === undefined ? undefined : listPrefixArgument(normalizedPrefix), {
|
|
188
|
+
signal,
|
|
189
|
+
...(windowCursor !== undefined ? { cursor: windowCursor } : {}),
|
|
190
|
+
limit: SHARED_MEMORY_LIST_PAGE_SIZE + 1,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
if (signal?.aborted === true)
|
|
195
|
+
throw err;
|
|
196
|
+
return refusedList(foldReaderError(err), "failed", resolution.diagnostics, store);
|
|
197
|
+
}
|
|
198
|
+
let windowEntries = raw;
|
|
199
|
+
let nextCursor;
|
|
200
|
+
let isPaged = false;
|
|
201
|
+
try {
|
|
202
|
+
if (raw !== null && typeof raw === "object" && !Array.isArray(raw) && Array.isArray(raw.entries)) {
|
|
203
|
+
isPaged = true;
|
|
204
|
+
windowEntries = raw.entries;
|
|
205
|
+
const nc = raw.nextCursor;
|
|
206
|
+
nextCursor = typeof nc === "string" ? nc : undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (signal?.aborted === true)
|
|
211
|
+
throw err;
|
|
212
|
+
return refusedList(FAILED_ERROR, "failed", resolution.diagnostics, store);
|
|
213
|
+
}
|
|
214
|
+
const normalized = normalizeEntries(windowEntries);
|
|
215
|
+
if (normalized.kind === "malformed")
|
|
216
|
+
return refusedList(FAILED_ERROR, "failed", resolution.diagnostics, store);
|
|
217
|
+
collected.push(...normalized.entries);
|
|
218
|
+
droppedEntries += normalized.droppedEntries;
|
|
219
|
+
const progressed = isPaged && nextCursor !== undefined && (windowCursor === undefined || nextCursor > windowCursor);
|
|
220
|
+
if (!progressed) {
|
|
221
|
+
providerHasMore = false;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
providerCursor = nextCursor;
|
|
225
|
+
providerHasMore = true;
|
|
226
|
+
const candidates = pageDocuments(collected, {
|
|
227
|
+
...(normalizedPrefix !== undefined ? { normalizedPrefix } : {}),
|
|
228
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
229
|
+
});
|
|
230
|
+
if (candidates.page.length >= SHARED_MEMORY_LIST_PAGE_SIZE || candidates.remaining > 0)
|
|
231
|
+
break;
|
|
232
|
+
if (round + 1 >= MAX_WINDOW_ROUNDS)
|
|
233
|
+
break;
|
|
234
|
+
windowCursor = nextCursor;
|
|
184
235
|
}
|
|
185
|
-
const
|
|
186
|
-
if (normalized.kind === "malformed")
|
|
187
|
-
return refusedList(FAILED_ERROR, "failed", resolution.diagnostics, store);
|
|
188
|
-
const { page, remaining } = pageDocuments(normalized.entries, {
|
|
236
|
+
const { page, remaining } = pageDocuments(collected, {
|
|
189
237
|
...(normalizedPrefix !== undefined ? { normalizedPrefix } : {}),
|
|
190
238
|
...(cursor !== undefined ? { cursor } : {}),
|
|
191
239
|
});
|
|
240
|
+
const hasMoreBeyondWindow = providerHasMore;
|
|
192
241
|
const details = {
|
|
193
|
-
...diagnosticsOf(resolution.diagnostics.droppedStores ?? 0,
|
|
242
|
+
...diagnosticsOf(resolution.diagnostics.droppedStores ?? 0, droppedEntries),
|
|
194
243
|
outcome: "ok",
|
|
195
244
|
mode: "documents",
|
|
196
245
|
store,
|
|
197
246
|
entries: page,
|
|
198
247
|
remaining,
|
|
248
|
+
...(hasMoreBeyondWindow ? { providerHasMore: true } : {}),
|
|
199
249
|
};
|
|
200
|
-
|
|
250
|
+
if (page.length === 0 && hasMoreBeyondWindow && providerCursor !== undefined) {
|
|
251
|
+
return {
|
|
252
|
+
content: `${SHARED_STORE_FRAME}\n\n(no matching documents in this range)\n… more — call again with cursor=${JSON.stringify(providerCursor)} to continue.`,
|
|
253
|
+
details,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return { content: renderDocumentPage(page, remaining, hasMoreBeyondWindow), details };
|
|
201
257
|
},
|
|
202
258
|
};
|
|
203
259
|
const readTool = {
|
|
@@ -69,15 +69,38 @@ export interface SharedMemoryDocumentEntry {
|
|
|
69
69
|
/** ISO 8601. Rendering uses only a matching 10-character `YYYY-MM-DD` prefix; no match ⇒ treated as absent. */
|
|
70
70
|
updatedAt?: string;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* #77 / design/180 实施窗 — the PROVIDER-SIDE pagination window. A reader that pages returns this
|
|
74
|
+
* instead of the bare array: `entries` is the window it chose to serve; `nextCursor` present ⇔ the
|
|
75
|
+
* store holds MORE documents past the window. The cursor vocabulary is exactly the tool face's —
|
|
76
|
+
* an EXCLUSIVE lower bound over document paths in plain UTF-16 code-unit order — so the same value
|
|
77
|
+
* flows model → core → provider unchanged. `nextCursor` is consumed as a HAS-MORE declaration only
|
|
78
|
+
* (the continue cursor the model receives is always the last path of the rendered page), so a
|
|
79
|
+
* stale or fabricated value can cost at most one empty page, never an error and never a skip.
|
|
80
|
+
*/
|
|
81
|
+
export interface SharedMemoryPagedList {
|
|
82
|
+
entries: SharedMemoryDocumentEntry[];
|
|
83
|
+
nextCursor?: string;
|
|
84
|
+
}
|
|
72
85
|
/** The read face of ONE store. Both methods must honor `signal`. */
|
|
73
86
|
export interface SharedMemoryStoreReader {
|
|
74
87
|
/**
|
|
75
88
|
* List the documents under `prefix` (`undefined` = the whole store). core sorts, re-filters and pages —
|
|
76
89
|
* an implementation may pre-filter by prefix but is only required to return a superset.
|
|
90
|
+
*
|
|
91
|
+
* #77 pagination pushdown (additive, both sides optional): core passes `cursor` (the exclusive
|
|
92
|
+
* lower-bound path of the previous page — same ordering contract as `pageDocuments`) and `limit`
|
|
93
|
+
* (a window-size hint, one over the render page) on every call. A provider may IGNORE both and
|
|
94
|
+
* return the full superset array — the v1 shape, byte-compatible — or honor them and return a
|
|
95
|
+
* {@link SharedMemoryPagedList} window, which is what lets a large library serve bounded
|
|
96
|
+
* containers instead of materializing the whole set per call (the container itself has no core
|
|
97
|
+
* cap; the unpaged form remains provider self-discipline, stated rather than silently truncated).
|
|
77
98
|
*/
|
|
78
99
|
list(prefix: string | undefined, opts: {
|
|
79
100
|
signal?: AbortSignal;
|
|
80
|
-
|
|
101
|
+
cursor?: string;
|
|
102
|
+
limit?: number;
|
|
103
|
+
}): Promise<SharedMemoryDocumentEntry[] | SharedMemoryPagedList>;
|
|
81
104
|
/** Read one document. `null` = it does not exist (absence is a VALUE here, never an exception). */
|
|
82
105
|
read(path: string, opts: {
|
|
83
106
|
signal?: AbortSignal;
|
|
@@ -171,6 +194,9 @@ export type MemoryListDetails = SharedMemoryDiagnostics & ({
|
|
|
171
194
|
store: string;
|
|
172
195
|
entries: SharedMemoryDocumentEntry[];
|
|
173
196
|
remaining: number;
|
|
197
|
+
/** #77 — the provider declared more documents past its served window (`remaining` counts only
|
|
198
|
+
* the window's local tail, so 0 here does not mean the listing is complete). */
|
|
199
|
+
providerHasMore?: true;
|
|
174
200
|
} | {
|
|
175
201
|
outcome: "refused" | "failed";
|
|
176
202
|
reason: string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/182 §11 — the permission-rule sync JOIN contract: the cross-repo acceptance suite the
|
|
3
|
+
* server-side twin runs against its own merge implementation (design/182 §13 obligation ②: consume
|
|
4
|
+
* core's `joinRuleStates`, or pass THIS suite against your reimplementation — semantic identity is the
|
|
5
|
+
* contract, not code sharing).
|
|
6
|
+
*
|
|
7
|
+
* KIT form (design/159 S1): vitest-free (`node:assert`), runner seam via {@link ContractAssertionRunner},
|
|
8
|
+
* version lockstep via `CONTRACT_KIT_ENGINE_VERSION`. There is NO skip hook — every case is mandatory,
|
|
9
|
+
* so a passing run never means "most of it passed". Fixtures are built inline: the join is a pure
|
|
10
|
+
* function, so there is nothing to seed and no instance lifecycle.
|
|
11
|
+
*
|
|
12
|
+
* What is asserted: the semilattice laws (idempotent/commutative/associative — the convergence proof
|
|
13
|
+
* itself, not prose), the design's convergence-matrix grids (M1-M13), the tombstone-union rule, the
|
|
14
|
+
* metadata re-projection refusal, the state vector and the paired frontier collection. Content
|
|
15
|
+
* assertions are set-based, never order-based: a twin with a different canonical output order passes,
|
|
16
|
+
* a twin that loses or invents a dot does not.
|
|
17
|
+
*/
|
|
18
|
+
import type { RuleSyncFrontier, RuleSyncState } from "../permission-rule-store.js";
|
|
19
|
+
import { type ContractAssertionRunner } from "./contract-harness.js";
|
|
20
|
+
export interface PermissionRuleSyncContractHooks {
|
|
21
|
+
/** The merge under test. Default: core's own `joinRuleStates` (self-verification). */
|
|
22
|
+
join?: (a: RuleSyncState, b: RuleSyncState) => RuleSyncState;
|
|
23
|
+
/** The paired-collection arm under test. Default: core's `collectBelowFrontier`. */
|
|
24
|
+
collect?: (state: RuleSyncState, frontier: RuleSyncFrontier) => {
|
|
25
|
+
state: RuleSyncState;
|
|
26
|
+
collectedTombstones: number;
|
|
27
|
+
};
|
|
28
|
+
/** The state vector under test. Default: core's `ruleSyncVector`. */
|
|
29
|
+
vector?: (state: RuleSyncState) => RuleSyncFrontier;
|
|
30
|
+
runAssertion?: ContractAssertionRunner;
|
|
31
|
+
}
|
|
32
|
+
/** Run the full conformance suite. Throws (assert) on the first violated clause. */
|
|
33
|
+
export declare function permissionRuleSyncContract(hooks?: PermissionRuleSyncContractHooks): Promise<void>;
|