@sema-agent/core 5.48.0 → 5.50.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 +112 -0
- package/dist/agents/agent-transcript-tool.d.ts +1 -1
- package/dist/agents/agent-transcript-tool.js +1 -1
- package/dist/agents/roster-store.js +4 -1
- package/dist/agents/send-message-tool.d.ts +2 -2
- package/dist/agents/send-message-tool.js +2 -2
- package/dist/agents/subagent.d.ts +6 -0
- package/dist/agents/subagent.js +126 -1
- package/dist/agents/teacher.d.ts +25 -1
- package/dist/agents/teacher.js +89 -13
- package/dist/brain/anthropic.js +11 -20
- package/dist/brain/open-responses.js +6 -14
- package/dist/brain/openai.js +6 -18
- package/dist/brain/reasoning.d.ts +100 -8
- package/dist/brain/reasoning.js +39 -15
- package/dist/brain/request-params.d.ts +37 -1
- package/dist/brain/request-params.js +40 -2
- package/dist/core/background-agent-store.d.ts +1 -1
- package/dist/core/background-agent-store.js +5 -4
- package/dist/core/mcp.d.ts +7 -1
- package/dist/core/mcp.js +64 -8
- package/dist/core/memory-engine/delegation-settlement.d.ts +27 -0
- package/dist/core/memory-engine/delegation-settlement.js +31 -4
- package/dist/core/memory-engine/dual-root.js +11 -0
- package/dist/core/memory-engine/engine.d.ts +36 -2
- package/dist/core/memory-engine/engine.js +354 -38
- package/dist/core/memory-engine/layout.d.ts +43 -0
- package/dist/core/memory-engine/layout.js +59 -0
- package/dist/core/memory-engine/memory-backend-contract.js +120 -0
- package/dist/core/memory-engine/origin-clearance.d.ts +19 -0
- package/dist/core/memory-engine/origin-clearance.js +10 -0
- package/dist/core/memory-engine/provenance-wording.d.ts +15 -1
- package/dist/core/memory-engine/provenance-wording.js +1 -0
- package/dist/core/memory-engine/tools.js +6 -4
- package/dist/core/memory-engine/types.d.ts +13 -1
- package/dist/core/runner/prepare-task.js +22 -8
- package/dist/core/runner/runtask.d.ts +26 -1
- package/dist/core/runner/runtask.js +18 -2
- package/dist/core/strategy-store.d.ts +180 -3
- package/dist/core/strategy-store.js +172 -23
- package/dist/core/task-registry-agent.js +6 -0
- package/dist/core/types.d.ts +24 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/orchestration/run-workflow-tool.d.ts +12 -0
- package/dist/orchestration/run-workflow-tool.js +1 -1
- package/dist/orchestration/workflow-governance.d.ts +27 -0
- package/dist/orchestration/workflow-governance.js +13 -0
- package/dist/orchestration/workflow-primitives.d.ts +8 -1
- package/dist/orchestration/workflow-primitives.js +11 -3
- package/dist/stores/file/file-snapshot-store.js +7 -1
- package/dist/stores/file/index.d.ts +8 -0
- package/dist/stores/file/index.js +12 -0
- package/dist/stores/file/session-policy-store.d.ts +0 -13
- package/dist/stores/file/session-policy-store.js +7 -1
- package/dist/stores/file/session-store.d.ts +4 -1
- package/dist/stores/file/session-store.js +7 -1
- package/dist/stores/file/strategy-store.d.ts +97 -0
- package/dist/stores/file/strategy-store.js +340 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +8 -1
|
@@ -125,6 +125,33 @@ export interface ResourceClampNote {
|
|
|
125
125
|
/** The effective value the child actually runs under (min of script/baseline/caps, or a forced ceiling). */
|
|
126
126
|
applied: number;
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* The worktree-overlay merge — `{ ...base, ...worktreeBase }` with ONE amendment: an overlay key
|
|
130
|
+
* whose value is OWN NULLISH (`undefined` OR `null` — both plain-JS deployment shapes) never
|
|
131
|
+
* overrides `base`; it reads as the absent key its value spells ("an absent overlay field inherits
|
|
132
|
+
* from base", the documented semantics). The GENERIC successor of the per-key protections that grew
|
|
133
|
+
* one field at a time (three face arrays, then `onAsk` — the mount-side deletes, which remain for
|
|
134
|
+
* their base-slot readings) while every OTHER same-shaped key stayed destructive: a surviving own
|
|
135
|
+
* nullish overlay key erased the base value — `toolPolicy` / `shellGate` / `limits` / `hooks` /
|
|
136
|
+
* `restoreGatedTools` / `readFace` / … — for exactly the children an untrusted script can route to
|
|
137
|
+
* the overlay (`isolation: "worktree"`), own-`undefined` keys included (a spread copies them; the
|
|
138
|
+
* old `=== null` face drop missed that half). One rule at the one merge point: a new baseline key
|
|
139
|
+
* is safe with no list to extend. The single exception is {@link NULL_VALUED_BASELINE_KEYS}
|
|
140
|
+
* (`checkpointStore: null` — the deployment disarming durable suspend for isolated children is a
|
|
141
|
+
* capability removal it must be able to spell). Explicit non-nullish overlay values keep winning
|
|
142
|
+
* wholesale — the documented deployment override.
|
|
143
|
+
*
|
|
144
|
+
* ACCEPTED COST, stated (adversarial-review round 2): the rule is direction-blind — a CAPABILITY
|
|
145
|
+
* key's out-of-type overlay `null` used to WITHHOLD the base value by the same spread accident
|
|
146
|
+
* (e.g. `worktreeBase: { getApiKeyAndHeaders: null }` overwrote the base resolver AND defeated the
|
|
147
|
+
* parent-credential `=== undefined` fill in workflow.ts — silently blocking the documented
|
|
148
|
+
* inheritance, the same seam-defeat disease the onAsk r3 ruling deleted null for), and such a key
|
|
149
|
+
* now inherits per the documented contract. Deployments withhold capabilities from isolated
|
|
150
|
+
* children with IN-TYPE spellings (`tools: []`, a deny `toolPolicy`, `handsReadOnly: true`);
|
|
151
|
+
* `getApiKeyAndHeaders` currently has no in-type disable value — that expressiveness gap is a
|
|
152
|
+
* baseline-contract question, deliberately not solved by resurrecting undocumented null semantics.
|
|
153
|
+
*/
|
|
154
|
+
export declare function overlayWorktreeBaseline(base: WorkflowGovernanceBaseline["base"], worktreeBase: WorkflowGovernanceBaseline["base"]): WorkflowGovernanceBaseline["base"];
|
|
128
155
|
/** Resolve an LLM-supplied model NAME to a deploy-configured `Model`, FAIL-CLOSED against the allowlist.
|
|
129
156
|
* Throws (never falls open to the whole catalog) when no allowlist is configured or the name is not on it. */
|
|
130
157
|
export declare function resolveModelName(name: string, allowlist: string[] | undefined, models: Record<string, Model> | undefined): Model;
|
|
@@ -57,6 +57,19 @@ function emitStrippedKeysNotice(survey, onNotice) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
60
|
+
const NULL_VALUED_BASELINE_KEYS = new Set(["checkpointStore"]);
|
|
61
|
+
export function overlayWorktreeBaseline(base, worktreeBase) {
|
|
62
|
+
let overlay = worktreeBase;
|
|
63
|
+
for (const key of Object.keys(worktreeBase)) {
|
|
64
|
+
const v = worktreeBase[key];
|
|
65
|
+
if (v !== undefined && (v !== null || NULL_VALUED_BASELINE_KEYS.has(key)))
|
|
66
|
+
continue;
|
|
67
|
+
if (overlay === worktreeBase)
|
|
68
|
+
overlay = { ...worktreeBase };
|
|
69
|
+
delete overlay[key];
|
|
70
|
+
}
|
|
71
|
+
return { ...base, ...overlay };
|
|
72
|
+
}
|
|
60
73
|
export function resolveModelName(name, allowlist, models) {
|
|
61
74
|
if (!allowlist || allowlist.length === 0) {
|
|
62
75
|
throw new WorkflowModelNotAllowedError(name, "no workflowModelAllowlist is configured (fail-closed: an LLM-authored script cannot pick a model)");
|
|
@@ -44,4 +44,11 @@ parentCheckpointStoreDisabled?: boolean,
|
|
|
44
44
|
parentReadFace?: () => TaskSpec["readFace"],
|
|
45
45
|
/** Twin of the above for the deny-set additions (built-ins always apply; these are the extra
|
|
46
46
|
* entries the host's own deployment/task layers stacked on). */
|
|
47
|
-
parentReadDenyPatterns?: () => TaskSpec["readDenyPatterns"]
|
|
47
|
+
parentReadDenyPatterns?: () => TaskSpec["readDenyPatterns"],
|
|
48
|
+
/** #345 ③ — the HOST task's write-hands clamp is ON (`spec.handsReadOnly === true`). Injected
|
|
49
|
+
* tighten-only into every spawned child, like the read-face clamp above; see the injection
|
|
50
|
+
* below for why it outranks even a `worktreeBase: { handsReadOnly: false }` overlay. */
|
|
51
|
+
parentHandsReadOnly?: boolean,
|
|
52
|
+
/** #345 ③ — the HOST task's hard-headless clamp is ON (`spec.interactiveTools === false`).
|
|
53
|
+
* Same tighten-only injection; only the disabling value ever arrives here as `true`. */
|
|
54
|
+
parentInteractiveToolsOff?: boolean): WorkflowPrimitives;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertSupportedAgentIsolation } from "./workflow.js";
|
|
2
|
-
import { buildGovernedChildSpec } from "./workflow-governance.js";
|
|
2
|
+
import { buildGovernedChildSpec, overlayWorktreeBaseline } from "./workflow-governance.js";
|
|
3
3
|
function safeAgentOptions(opts) {
|
|
4
4
|
if (typeof opts !== "object" || opts === null)
|
|
5
5
|
return {};
|
|
@@ -22,12 +22,12 @@ function formatResourceClampNote(notes) {
|
|
|
22
22
|
const parts = notes.map((n) => `${n.field}: requested ${n.requested === undefined ? "unset" : n.requested} → applied ${n.applied}`);
|
|
23
23
|
return `workflow governance tightened this agent's resource limits (${parts.join("; ")})`;
|
|
24
24
|
}
|
|
25
|
-
export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThinking, parentPrincipal, parentCheckpointStoreDisabled, parentReadFace, parentReadDenyPatterns) {
|
|
25
|
+
export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThinking, parentPrincipal, parentCheckpointStoreDisabled, parentReadFace, parentReadDenyPatterns, parentHandsReadOnly, parentInteractiveToolsOff) {
|
|
26
26
|
const agent = (spec, opts) => {
|
|
27
27
|
if (typeof spec === "string")
|
|
28
28
|
spec = { objective: spec };
|
|
29
29
|
const agentOpts = safeAgentOptions(opts);
|
|
30
|
-
const effectiveBaseline = (b) => agentOpts.isolation === "worktree" && b.worktreeBase
|
|
30
|
+
const effectiveBaseline = (b) => agentOpts.isolation === "worktree" && b.worktreeBase != null ? { ...b, base: overlayWorktreeBaseline(b.base, b.worktreeBase) } : b;
|
|
31
31
|
const childSpec = governance
|
|
32
32
|
? buildGovernedChildSpec(spec, effectiveBaseline(governance.baseline), governance.models, governance.caps, (notes) => ctx.log(formatResourceClampNote(notes)), governance.onNotice)
|
|
33
33
|
: { ...spec };
|
|
@@ -53,6 +53,14 @@ export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThi
|
|
|
53
53
|
childSpec.readDenyPatterns = childSpec.readDenyPatterns !== undefined ? [...childSpec.readDenyPatterns, ...pd] : [...pd];
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
if (parentHandsReadOnly === true) {
|
|
57
|
+
childSpec.handsReadOnly = true;
|
|
58
|
+
}
|
|
59
|
+
if (parentInteractiveToolsOff === true) {
|
|
60
|
+
childSpec.interactiveTools = false;
|
|
61
|
+
if (childSpec.interactionPosture === "interactive")
|
|
62
|
+
delete childSpec.interactionPosture;
|
|
63
|
+
}
|
|
56
64
|
if (onAgentSpawn) {
|
|
57
65
|
return ctx.agentStream(childSpec, agentOpts).then((handle) => {
|
|
58
66
|
onAgentSpawn(handle);
|
|
@@ -5,6 +5,12 @@ import { applyManifest, captureManifest, DEFAULT_SNAPSHOT_BOUNDS, } from "../../
|
|
|
5
5
|
import { canonicalStoreKey, ensureDir, sanitizePathComponent, sanitizeScope, writeThenLink } from "./fs-atomic.js";
|
|
6
6
|
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
7
7
|
const sharedInFlight = new Map();
|
|
8
|
+
function containSinkThenable(r) {
|
|
9
|
+
if (typeof r?.then === "function") {
|
|
10
|
+
r.then(undefined, () => {
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
}
|
|
8
14
|
export class FileFileSnapshotStore {
|
|
9
15
|
base;
|
|
10
16
|
blobsDir;
|
|
@@ -37,7 +43,7 @@ export class FileFileSnapshotStore {
|
|
|
37
43
|
}
|
|
38
44
|
disclose(path, reason) {
|
|
39
45
|
try {
|
|
40
|
-
this.onCorruptRead?.({ path, reason });
|
|
46
|
+
containSinkThenable(this.onCorruptRead?.({ path, reason }));
|
|
41
47
|
}
|
|
42
48
|
catch {
|
|
43
49
|
}
|
|
@@ -4,6 +4,7 @@ import type { ToolResultStore } from "../../core/tool-result-store.js";
|
|
|
4
4
|
import { type EvictPolicy } from "../../core/session-store.js";
|
|
5
5
|
import type { SessionStore } from "../../core/session.js";
|
|
6
6
|
import { type FileCheckpointStoreOptions } from "./checkpoint-store.js";
|
|
7
|
+
import type { StrategyStore } from "../../core/strategy-store.js";
|
|
7
8
|
import type { BackgroundAgentStore } from "../../core/background-agent-store.js";
|
|
8
9
|
import type { MailboxStore } from "../../core/mailbox-store.js";
|
|
9
10
|
import type { RosterStore } from "../../agents/roster-store.js";
|
|
@@ -19,6 +20,7 @@ export { FileSessionPolicyStore, type FileSessionPolicyStoreOptions, type Sessio
|
|
|
19
20
|
export { FileFileSnapshotStore, type FileFileSnapshotStoreOptions } from "./file-snapshot-store.js";
|
|
20
21
|
export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "./workflow-journal-store.js";
|
|
21
22
|
export { FileUsageWindowStore } from "./usage-window-store.js";
|
|
23
|
+
export { FileStrategyStore, type FileStrategyStoreOptions } from "./strategy-store.js";
|
|
22
24
|
export { resolveDataRoot, sanitizeScope, sanitizePathComponent, createFileConsolidationLock } from "./fs-atomic.js";
|
|
23
25
|
export { atomicWriteFile, writeThenLink, ensureDir, readJsonlRecords, AppendLog } from "./fs-atomic.js";
|
|
24
26
|
export { AdoptionError, assertAdoptionBootGate, readRootAdoptionFile, writeRootAdoptionFile, ROOT_ADOPTION_FILE, type AdoptionErrorCode, type AdoptionSource, type AdoptionReport, type AdoptionReceipt, type AdoptionLegReport, type AffectedDeploymentConfig, type RootAdoptionFile, } from "./adoption/marker.js";
|
|
@@ -136,6 +138,12 @@ export declare class FileStorageBackend {
|
|
|
136
138
|
/** design/151 §7 S3c — the durable SendMessage mailbox (tier-3 parking lane), same data root.
|
|
137
139
|
* Wire into `RunnerDeps.mailboxStore` (single-instance pairing, same as above). */
|
|
138
140
|
readonly mailboxStore: MailboxStore;
|
|
141
|
+
/** File-backed teacher-mode strategy repository (`root/strategies`) — pass as
|
|
142
|
+
* `TeacherConfig.strategyStore` (with a `scope`) so escalation strategies survive a restart;
|
|
143
|
+
* the in-memory default forgets everything each process, which zeroes the cross-session reuse
|
|
144
|
+
* the repository exists for. Aggregated here so the TOC full-persistence assembly stays one
|
|
145
|
+
* constructor (the same half-wiring hazard as the delegation trio). */
|
|
146
|
+
readonly strategyStore: StrategyStore;
|
|
139
147
|
/** design/147 S1c — the durable name→agent roster (layer 0.5 resolution), `root/roster.json`.
|
|
140
148
|
* Wire into `RunnerDeps.rosterStore` so name-addressing survives a restart alongside the rows. */
|
|
141
149
|
readonly rosterStore: RosterStore;
|
|
@@ -14,6 +14,7 @@ import { FileUsageWindowStore } from "./usage-window-store.js";
|
|
|
14
14
|
import { FileBackgroundAgentStore } from "./background-agent-store.js";
|
|
15
15
|
import { FileMailboxStore } from "./mailbox-store.js";
|
|
16
16
|
import { FileRosterStore } from "../../agents/roster-store.js";
|
|
17
|
+
import { FileStrategyStore } from "./strategy-store.js";
|
|
17
18
|
export { FileCheckpointStore } from "./checkpoint-store.js";
|
|
18
19
|
export { FileMemoryStore } from "./memory-store.js";
|
|
19
20
|
export { FileSessionRepo } from "./session-store.js";
|
|
@@ -22,6 +23,7 @@ export { FileSessionPolicyStore } from "./session-policy-store.js";
|
|
|
22
23
|
export { FileFileSnapshotStore } from "./file-snapshot-store.js";
|
|
23
24
|
export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "./workflow-journal-store.js";
|
|
24
25
|
export { FileUsageWindowStore } from "./usage-window-store.js";
|
|
26
|
+
export { FileStrategyStore } from "./strategy-store.js";
|
|
25
27
|
export { resolveDataRoot, sanitizeScope, sanitizePathComponent, createFileConsolidationLock } from "./fs-atomic.js";
|
|
26
28
|
export { atomicWriteFile, writeThenLink, ensureDir, readJsonlRecords, AppendLog } from "./fs-atomic.js";
|
|
27
29
|
export { AdoptionError, assertAdoptionBootGate, readRootAdoptionFile, writeRootAdoptionFile, ROOT_ADOPTION_FILE, } from "./adoption/marker.js";
|
|
@@ -38,6 +40,7 @@ export class FileStorageBackend {
|
|
|
38
40
|
usageWindowStore;
|
|
39
41
|
backgroundAgentStore;
|
|
40
42
|
mailboxStore;
|
|
43
|
+
strategyStore;
|
|
41
44
|
rosterStore;
|
|
42
45
|
consolidationLock;
|
|
43
46
|
lock;
|
|
@@ -74,6 +77,15 @@ export class FileStorageBackend {
|
|
|
74
77
|
this.fileMailbox = new FileMailboxStore(this.root, corruptRead ?? {});
|
|
75
78
|
this.mailboxStore = this.fileMailbox;
|
|
76
79
|
this.rosterStore = new FileRosterStore(join(this.root, "roster.json"), corruptRead ?? {});
|
|
80
|
+
const strategiesRoot = join(this.root, "strategies");
|
|
81
|
+
this.strategyStore = new FileStrategyStore({
|
|
82
|
+
root: strategiesRoot,
|
|
83
|
+
...(opts.onCorruptRead !== undefined
|
|
84
|
+
? {
|
|
85
|
+
onIncident: (i) => opts.onCorruptRead?.({ path: i.path ?? strategiesRoot, reason: `strategy ${i.op}: ${i.error}` }),
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
88
|
+
});
|
|
77
89
|
this.consolidationLock = createFileConsolidationLock(join(this.root, "consolidation-locks"));
|
|
78
90
|
}
|
|
79
91
|
catch (err) {
|
|
@@ -1,17 +1,4 @@
|
|
|
1
1
|
import { type PutRulesOptions, type SessionPermissionRules, type SessionPolicyStore, type SessionRulesRecord, type StoredSessionRules } from "../../core/session-policy-store.js";
|
|
2
|
-
/**
|
|
3
|
-
* design/99 §E6 — file-backed {@link SessionPolicyStore} for the local (TOC) backend: ONE JSON file per
|
|
4
|
-
* `(sessionId, principal)`, semantics **byte-for-byte identical to `InMemorySessionPolicyStore`** (the
|
|
5
|
-
* cross-backend equivalence contract). CAS-rev OCC + tighten-only reuse the SAME pure helpers
|
|
6
|
-
* (`loosenReasons`/`normalizeRules`/`stripRev`) as core — no re-implemented rule logic that could drift.
|
|
7
|
-
*
|
|
8
|
-
* Atomicity: the file backend is single-process (the `FileStorageBackend` boot lock guarantees ONE writer per
|
|
9
|
-
* data dir), and `getRules`/`putRules` read-check-write SYNCHRONOUSLY (no await between the rev read and the
|
|
10
|
-
* atomic write), so the read-modify-write is atomic in the one event loop — exactly the InMemory store's premise.
|
|
11
|
-
* Cross-process CORRECT concurrency is the Pg/TiDB backend's job (a CAS WHERE clause), by design.
|
|
12
|
-
*/
|
|
13
|
-
/** Coordinates of one corrupt-treated-as-absent policy read (the {@link FileSessionPolicyStoreOptions.onCorruptRead}
|
|
14
|
-
* payload). Named rather than inline so the backend option that forwards it names the SAME shape. */
|
|
15
2
|
export interface SessionPolicyCorruptReadInfo {
|
|
16
3
|
/** The session whose read observed the corrupt row. On the enumeration face this is the session being
|
|
17
4
|
* ENUMERATED — a corrupt row's own `__sid` is by definition unreadable, so it cannot be attributed. */
|
|
@@ -3,6 +3,12 @@ import { join } from "node:path";
|
|
|
3
3
|
import { loosenReasons, normalizeRules, stripRev, SessionPolicyError, } from "../../core/session-policy-store.js";
|
|
4
4
|
import { atomicWriteFile, ensureDir, sanitizeScope } from "./fs-atomic.js";
|
|
5
5
|
import { assertAdoptionBootGate, readRootAdoptionFile } from "./adoption/marker.js";
|
|
6
|
+
function containSinkThenable(r) {
|
|
7
|
+
if (typeof r?.then === "function") {
|
|
8
|
+
r.then(undefined, () => {
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|
|
6
12
|
export class FileSessionPolicyStore {
|
|
7
13
|
dir;
|
|
8
14
|
onCorruptRead;
|
|
@@ -18,7 +24,7 @@ export class FileSessionPolicyStore {
|
|
|
18
24
|
}
|
|
19
25
|
disclose(info) {
|
|
20
26
|
try {
|
|
21
|
-
this.onCorruptRead?.(info);
|
|
27
|
+
containSinkThenable(this.onCorruptRead?.(info));
|
|
22
28
|
}
|
|
23
29
|
catch {
|
|
24
30
|
}
|
|
@@ -29,7 +29,10 @@ export declare class FileSessionRepo implements SessionRepo {
|
|
|
29
29
|
private readonly joined;
|
|
30
30
|
constructor(root: string, opts?: FileSessionRepoOptions);
|
|
31
31
|
/** The one delivery point for {@link FileSessionRepoOptions.onCorruptRead}; swallow-guarded here so
|
|
32
|
-
* no call site has to remember.
|
|
32
|
+
* no call site has to remember. The sink seat is void-typed but a host may hand it an async
|
|
33
|
+
* function — an async sink's rejection is observed off its returned thenable (same containment
|
|
34
|
+
* as the strategy store's incident sink), so neither a sync throw nor an async rejection can
|
|
35
|
+
* re-introduce the failure mode the fail-open avoids. */
|
|
33
36
|
private disclose;
|
|
34
37
|
private pathFor;
|
|
35
38
|
/** Read + torn-tail-recover a session file into (meta, entries); a missing file → not_found. */
|
|
@@ -3,6 +3,12 @@ import { join } from "node:path";
|
|
|
3
3
|
import { BaseSessionStorage, StoredSession, SessionError, getEntriesToFork, uuidv7, validateEntriesForImport, } from "../../internal/harness.js";
|
|
4
4
|
import { canonicalStoreKey, AppendLog, atomicWriteFile, ensureDir, readJsonlRecords, sanitizePathComponent } from "./fs-atomic.js";
|
|
5
5
|
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
6
|
+
function containSinkThenable(r) {
|
|
7
|
+
if (typeof r?.then === "function") {
|
|
8
|
+
r.then(undefined, () => {
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|
|
6
12
|
const SUFFIX = ".jsonl";
|
|
7
13
|
const sharedSessionStorages = new Map();
|
|
8
14
|
const sessionStorageFinalizer = new FinalizationRegistry(({ canonical, log }) => {
|
|
@@ -67,7 +73,7 @@ export class FileSessionRepo {
|
|
|
67
73
|
}
|
|
68
74
|
disclose(path, reason) {
|
|
69
75
|
try {
|
|
70
|
-
this.onCorruptRead?.({ path, reason });
|
|
76
|
+
containSinkThenable(this.onCorruptRead?.({ path, reason }));
|
|
71
77
|
}
|
|
72
78
|
catch {
|
|
73
79
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type StoredStrategy, type StrategyStore, type StrategyStoreIncident } from "../../core/strategy-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* File-backed {@link StrategyStore} — the persistence twin of `InMemoryStrategyStore`, so the teacher
|
|
4
|
+
* mode's cross-session reuse premise actually holds on a local (TOC) deployment where every session is
|
|
5
|
+
* a fresh process (an in-memory strategy repository there re-learns everything, every time).
|
|
6
|
+
*
|
|
7
|
+
* DESIGN STANCE — this is a **cache, not an authority store**: every entry is regenerable (worst case:
|
|
8
|
+
* ask the teacher again), so losing one never loses correctness. It therefore deliberately skips the
|
|
9
|
+
* journal/shadow armor of the memory file backend and does NOT fsync-harden beyond the shared
|
|
10
|
+
* atomic-write primitive, does NOT take a cross-process lock, and accepts a narrow multi-process race
|
|
11
|
+
* on save-side dedup (two processes can each write one copy of the same normalized entry — the read
|
|
12
|
+
* side dedups, and a lost max-confidence merge is cache-grade). What it does NOT relax: scope
|
|
13
|
+
* isolation and loud-refusal discipline, which are held to the same standard as the durable stores.
|
|
14
|
+
*
|
|
15
|
+
* Layout: `<root>/<scopeDir>/<id>.json`, one strategy per file, whole-file atomic replace on write.
|
|
16
|
+
* `scopeDir = slug(scope) + "-" + sha256(scope).slice(0,24)` — the slug is a strict `[a-z0-9-]`
|
|
17
|
+
* whitelist purely for readability; IDENTITY lives in the 96-bit hash, so no scope value, however
|
|
18
|
+
* hostile, can traverse out of the root or collide another scope's directory. Ids are gated to
|
|
19
|
+
* `[A-Za-z0-9_-]{1,64}` at save (the escalation loop mints UUIDs; the interface is public, so a
|
|
20
|
+
* hand-rolled id must not be able to name a path).
|
|
21
|
+
*
|
|
22
|
+
* `scope` is a NAMESPACE, not an authorization boundary — the store cannot authenticate its caller;
|
|
23
|
+
* whoever holds the instance can address any scope (same posture as the memory store). Authorization
|
|
24
|
+
* is the host's obligation.
|
|
25
|
+
*/
|
|
26
|
+
export interface FileStrategyStoreOptions {
|
|
27
|
+
/** Directory to keep strategies under (created `0o700` if absent; a non-directory or unwritable
|
|
28
|
+
* path is refused loudly at construction — a store that cannot persist must not pretend to). */
|
|
29
|
+
root: string;
|
|
30
|
+
/** Per-scope capacity cap (entries), evicting the lowest-scoring on overflow. Default 100. */
|
|
31
|
+
maxPerScope?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Disclosure sink for contained store incidents (a corrupt entry quarantined, an eviction that
|
|
34
|
+
* failed after a successful save, a degraded read). Absent ⇒ `console.warn`, once per op kind —
|
|
35
|
+
* a contained fault must be loud somewhere, but must never flood.
|
|
36
|
+
*/
|
|
37
|
+
onIncident?: (i: StrategyStoreIncident) => void;
|
|
38
|
+
}
|
|
39
|
+
export declare class FileStrategyStore implements StrategyStore {
|
|
40
|
+
private readonly root;
|
|
41
|
+
private readonly maxPerScope;
|
|
42
|
+
private readonly onIncident?;
|
|
43
|
+
/** Ops that already warned on the absent-sink fallback (bounded disclosure — never flood). */
|
|
44
|
+
private readonly warnedOps;
|
|
45
|
+
/** Isolation scope for the host incident sink (sync throw AND async rejection contained). */
|
|
46
|
+
private readonly sinkNotifier;
|
|
47
|
+
/** Scope dirs whose stale temps this instance already swept (once per scope per instance). */
|
|
48
|
+
private readonly sweptTemps;
|
|
49
|
+
constructor(opts: FileStrategyStoreOptions);
|
|
50
|
+
/** `slug(scope)-sha256(scope)[0..24]` — slug is readability only; the hash is the injective key. */
|
|
51
|
+
private scopeDirName;
|
|
52
|
+
private scopeDirPath;
|
|
53
|
+
/** The scope dir must be a REAL directory — a symlink here means the store's namespace was tampered
|
|
54
|
+
* with (a link could point retrieval at another scope's data, or writes out of the root). The
|
|
55
|
+
* check-then-use window against a same-uid local attacker is out of the threat model: such an
|
|
56
|
+
* attacker already holds the same rights as the store itself. */
|
|
57
|
+
private assertScopeDirSafe;
|
|
58
|
+
private incident;
|
|
59
|
+
/** Sweep crashed writers' stale temps (once per scope per instance). Fresh temps are left alone —
|
|
60
|
+
* they may belong to a live concurrent writer. */
|
|
61
|
+
private sweepStaleTemps;
|
|
62
|
+
/** Load every VALID entry of a scope dir. Corrupt files are quarantined (`.bad` rename) with one
|
|
63
|
+
* disclosure; entries that violate the read-side invariants (foreign scope / id≠filename) are
|
|
64
|
+
* skipped with disclosure but NOT quarantined (a copied-in file may be someone's valid data);
|
|
65
|
+
* normalized duplicates collapse to the best-scoring copy. */
|
|
66
|
+
private loadScope;
|
|
67
|
+
/** Rename a corrupt file to `<name>.bad` — one disclosure now, zero parse cost on every later read
|
|
68
|
+
* (the suffix no longer matches the entry-file grammar). Never faults the calling operation. */
|
|
69
|
+
private quarantine;
|
|
70
|
+
/** Serialize + write one entry, refusing a record whose SERIALIZED form exceeds the read bound.
|
|
71
|
+
* The field caps bound RAW string bytes, but JSON escaping expands control characters up to 6x —
|
|
72
|
+
* without this door a save could succeed and then self-quarantine on the very next read (the
|
|
73
|
+
* worst possible shape: an accepted write the store itself later refuses to serve). */
|
|
74
|
+
private writeEntry;
|
|
75
|
+
save(s: StoredStrategy): void;
|
|
76
|
+
private evict;
|
|
77
|
+
find(scope: string, query: string, limit: number): StoredStrategy[];
|
|
78
|
+
/** Bounded candidate view for the RETRIEVAL-adjacent paths (find/hasStrategy/save): an externally
|
|
79
|
+
* inflated directory must not make a hot operation unbounded. MAINTENANCE passes Infinity — see
|
|
80
|
+
* {@link prune}. */
|
|
81
|
+
private retrievalReadCap;
|
|
82
|
+
/**
|
|
83
|
+
* Trims to `maxSize` over an UNCAPPED enumeration: prune is the reconciliation verb, so it must see
|
|
84
|
+
* every entry file — under the bounded retrieval view, a capacity SHRINK across restarts (or
|
|
85
|
+
* `maxPerScope: 0`) left files beyond the window untouched, reporting success while the "removed"
|
|
86
|
+
* strategies sat on disk ready to resurrect under a later, larger capacity. Host-invoked
|
|
87
|
+
* maintenance accepts the O(all files) cost the retrieval path refuses.
|
|
88
|
+
*/
|
|
89
|
+
prune(scope: string, maxSize: number): void;
|
|
90
|
+
/** Uncapped for the same reason as {@link prune}: this face feeds the seed capacity door, and an
|
|
91
|
+
* under-count there turns "refuse what cannot fit" into silent eviction of fresh seeds. */
|
|
92
|
+
scopeUsage(scope: string): {
|
|
93
|
+
used: number;
|
|
94
|
+
capacity: number;
|
|
95
|
+
};
|
|
96
|
+
hasStrategy(scope: string, entry: Pick<StoredStrategy, "problem" | "strategy">): boolean;
|
|
97
|
+
}
|