forgeos 0.1.0-alpha.63 → 0.1.0-alpha.65

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.
Files changed (82) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/docs/agent-fabric.md +416 -0
  4. package/package.json +7 -4
  5. package/schemas/agent-fabric/v0.1/control-event.schema.json +281 -0
  6. package/scripts/field-test-forgeos.mjs +11 -2
  7. package/src/forge/_generated/releaseManifest.json +1 -1
  8. package/src/forge/_generated/releaseManifest.ts +3 -3
  9. package/src/forge/agent-adapters/index.ts +18 -14
  10. package/src/forge/agent-adapters/types.ts +1 -1
  11. package/src/forge/agent-fabric/adapter.ts +131 -0
  12. package/src/forge/agent-fabric/authority.ts +298 -0
  13. package/src/forge/agent-fabric/canonical.ts +135 -0
  14. package/src/forge/agent-fabric/conductor.ts +754 -0
  15. package/src/forge/agent-fabric/dictionary.ts +12 -0
  16. package/src/forge/agent-fabric/errors.ts +32 -0
  17. package/src/forge/agent-fabric/hardened-conductor.ts +485 -0
  18. package/src/forge/agent-fabric/hardened-reducer.ts +211 -0
  19. package/src/forge/agent-fabric/index.ts +22 -0
  20. package/src/forge/agent-fabric/journal.ts +116 -0
  21. package/src/forge/agent-fabric/local-adaptive-digest-worker.mjs +26 -0
  22. package/src/forge/agent-fabric/local-adaptive-harness.ts +343 -0
  23. package/src/forge/agent-fabric/local-adaptive-service.ts +398 -0
  24. package/src/forge/agent-fabric/local-adaptive-worker.ts +146 -0
  25. package/src/forge/agent-fabric/local-approval-window.ts +211 -0
  26. package/src/forge/agent-fabric/local-coding-worker.ts +285 -0
  27. package/src/forge/agent-fabric/local-control-store.ts +239 -0
  28. package/src/forge/agent-fabric/local-effect-broker.ts +348 -0
  29. package/src/forge/agent-fabric/local-evolution-approval.ts +81 -0
  30. package/src/forge/agent-fabric/local-evolution-profile.ts +87 -0
  31. package/src/forge/agent-fabric/local-evolution-registry.ts +427 -0
  32. package/src/forge/agent-fabric/local-evolution-service.ts +200 -0
  33. package/src/forge/agent-fabric/local-intelligence.ts +281 -0
  34. package/src/forge/agent-fabric/local-paths.ts +18 -0
  35. package/src/forge/agent-fabric/local-task-contract.ts +203 -0
  36. package/src/forge/agent-fabric/local-task-inbox.ts +543 -0
  37. package/src/forge/agent-fabric/local-task-server.ts +249 -0
  38. package/src/forge/agent-fabric/local-task-service.ts +982 -0
  39. package/src/forge/agent-fabric/local-verification.ts +551 -0
  40. package/src/forge/agent-fabric/p0a.ts +104 -0
  41. package/src/forge/agent-fabric/p0b-model-adapter.ts +434 -0
  42. package/src/forge/agent-fabric/planning.ts +155 -0
  43. package/src/forge/agent-fabric/reducer.ts +863 -0
  44. package/src/forge/agent-fabric/resource-ledger.ts +266 -0
  45. package/src/forge/agent-fabric/serialized-local-adapter.ts +60 -0
  46. package/src/forge/agent-fabric/types.ts +424 -0
  47. package/src/forge/agent-fabric/validation.ts +500 -0
  48. package/src/forge/agent-memory/bridge.ts +223 -90
  49. package/src/forge/agent-memory/mcp.ts +71 -9
  50. package/src/forge/agent-memory/sources/codex-hook-runner.mjs +77 -116
  51. package/src/forge/cli/adaptive.ts +51 -0
  52. package/src/forge/cli/changed.ts +13 -32
  53. package/src/forge/cli/commands.ts +26 -4
  54. package/src/forge/cli/evolution.ts +40 -0
  55. package/src/forge/cli/fabric.ts +131 -0
  56. package/src/forge/cli/main.ts +24 -0
  57. package/src/forge/cli/new.ts +16 -4
  58. package/src/forge/cli/parse.ts +61 -0
  59. package/src/forge/cli/studio.ts +141 -77
  60. package/src/forge/cli/verify.ts +4 -1
  61. package/src/forge/compiler/app-graph/tsconfig-hash.ts +41 -4
  62. package/src/forge/compiler/package-graph/dts-extractor.ts +14 -1
  63. package/src/forge/dev-console/cycle.ts +6 -1
  64. package/src/forge/impact/index.ts +18 -21
  65. package/src/forge/review/index.ts +6 -2
  66. package/src/forge/runtime/ai/providers.ts +19 -4
  67. package/src/forge/version.ts +1 -1
  68. package/src/forge/workspace/change-summary.ts +37 -7
  69. package/src/forge/workspace/git-summary.ts +27 -25
  70. package/templates/agent-workroom/package.json +1 -0
  71. package/templates/agent-workroom/pnpm-workspace.yaml +2 -0
  72. package/templates/b2b-support-web/package.json +1 -0
  73. package/templates/b2b-support-web/pnpm-workspace.yaml +2 -0
  74. package/templates/b2b-support-web/web/package.json +1 -1
  75. package/templates/minimal-web/package.json +1 -0
  76. package/templates/minimal-web/pnpm-workspace.yaml +2 -0
  77. package/templates/nuxt-web/.yarnrc.yml +1 -0
  78. package/templates/nuxt-web/package.json +1 -0
  79. package/templates/nuxt-web/pnpm-workspace.yaml +2 -0
  80. package/templates/nuxt-web/web/package.json +1 -0
  81. package/templates/vendor-access/package.json +1 -0
  82. package/templates/vendor-access/pnpm-workspace.yaml +2 -0
@@ -0,0 +1,398 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import type { AddressInfo } from "node:net";
6
+ import { createPgliteAdapter } from "../runtime/db/pglite-adapter.ts";
7
+ import { digestCanonical, sha256Digest } from "./canonical.ts";
8
+ import { AgentFabricError } from "./errors.ts";
9
+ import { LocalAdaptiveHarness, compileLocalAdaptiveRevision, type LocalAdaptiveProcessResult } from "./local-adaptive-harness.ts";
10
+ import { LocalControlStore } from "./local-control-store.ts";
11
+ import { LocalEvolutionService } from "./local-evolution-service.ts";
12
+ import { assertLocalAdaptiveInputs, LOCAL_ADAPTIVE_EXTENSION_KEY, parseLocalAdaptiveInputProfile } from "./local-evolution-profile.ts";
13
+ import { localFabricPath } from "./local-paths.ts";
14
+ import { replayControlState } from "./hardened-reducer.ts";
15
+ import type { EvolutionChannel } from "./local-evolution-registry.ts";
16
+ import type { Clock, Digest, GoalContract, OwnerAuthorization, OwnerAuthorizationVerifier } from "./types.ts";
17
+
18
+ const WORKERS = [{ resource: "workers", semantics: "capacity" as const, limit: 2 }];
19
+ const clock: Clock = { now: () => Date.now() };
20
+ const MAX_TEXT = 256;
21
+
22
+ export interface AdaptiveInput { inventory: string; constraints: string }
23
+ type Phase = "proposed" | "rejected" | "approved" | "running" | "succeeded" | "blocked" | "uncertain";
24
+ interface AdaptiveRecord {
25
+ schemaVersion: 1;
26
+ id: string;
27
+ repositoryRoot: string;
28
+ input: AdaptiveInput;
29
+ inputDigest: Digest;
30
+ profile?: { channel: EvolutionChannel; versionId: string };
31
+ phase: Phase;
32
+ createdAt: number;
33
+ approvedAt?: number;
34
+ approvalReceipt?: Digest;
35
+ startedAt?: number;
36
+ finishedAt?: number;
37
+ result?: LocalAdaptiveProcessResult;
38
+ resultReceipt?: Digest;
39
+ failure?: string;
40
+ }
41
+ export interface AdaptiveStatus {
42
+ id: string;
43
+ phase: Phase;
44
+ inputDigest: Digest;
45
+ profile?: { channel: EvolutionChannel; versionId: string };
46
+ result?: LocalAdaptiveProcessResult;
47
+ failure?: string;
48
+ journal: { events: number; childOutcomes: number; joinOutcome?: { status: string; resultDigest: Digest; reportDigest: Digest } };
49
+ }
50
+ type Approval = (view: { repositoryRoot: string; id: string; input: AdaptiveInput; digest: Digest;
51
+ profile?: { channel: EvolutionChannel; versionId: string } }) => Promise<"approved" | "rejected">;
52
+
53
+ function validateInput(value: unknown): AdaptiveInput {
54
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
55
+ Object.keys(value).sort().join(",") !== "constraints,inventory") {
56
+ throw new AgentFabricError("AF_INVALID_STATE", "Expected exactly inventory and constraints text");
57
+ }
58
+ const input = value as Record<string, unknown>;
59
+ for (const field of ["inventory", "constraints"] as const) {
60
+ if (typeof input[field] !== "string" || input[field].length > MAX_TEXT ||
61
+ Buffer.byteLength(input[field], "utf8") > MAX_TEXT) {
62
+ throw new AgentFabricError("AF_INVALID_STATE", `${field} must be at most 256 UTF-8 bytes`);
63
+ }
64
+ }
65
+ return { inventory: input.inventory as string, constraints: input.constraints as string };
66
+ }
67
+
68
+ function ids(id: string) {
69
+ return { root: `adaptive:${id}`, authorization: `auth:${id}`, goal: `goal:${id}`,
70
+ revision: `plan:${id}`, grant: `grant:${id}:coordinator` };
71
+ }
72
+
73
+ function readRecord(path: string): AdaptiveRecord {
74
+ if (statSync(path).size > 16 * 1024) throw new AgentFabricError("AF_INVALID_STATE", "Adaptive record exceeds byte limit");
75
+ const value = JSON.parse(readFileSync(path, "utf8")) as AdaptiveRecord;
76
+ validateInput(value.input);
77
+ if (value.schemaVersion !== 1 || !/^[a-f0-9]{32}$/u.test(value.id) ||
78
+ digestCanonical(value.input, sha256Digest) !== value.inputDigest ||
79
+ (value.profile && (typeof value.profile !== "object" ||
80
+ (value.profile.channel !== "canary" && value.profile.channel !== "stable") ||
81
+ !/^extension:sha256:[0-9a-f]{64}$/u.test(value.profile.versionId))) ||
82
+ !["proposed", "rejected", "approved", "running", "succeeded", "blocked", "uncertain"].includes(value.phase)) {
83
+ throw new AgentFabricError("AF_INVALID_STATE", "Adaptive record failed validation");
84
+ }
85
+ return value;
86
+ }
87
+
88
+ function writeRecord(path: string, value: AdaptiveRecord): void {
89
+ const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
90
+ writeFileSync(temporary, JSON.stringify(value), { encoding: "utf8", mode: 0o600, flag: "wx" });
91
+ renameSync(temporary, path);
92
+ }
93
+
94
+ function ownerVerifier(key: Buffer, approvals: Set<Digest>): OwnerAuthorizationVerifier {
95
+ const evidence = (digest: Digest): Digest => `sha256:${createHmac("sha256", key)
96
+ .update("forge-local-adaptive-owner/v1:").update(digest).digest("hex")}`;
97
+ return {
98
+ verify(_authorization, digest) {
99
+ if (!approvals.delete(digest)) throw new AgentFabricError("AF_GRANT_REJECTED", "Owner approval is absent");
100
+ return { verifierId: "forge-local-adaptive-popup/v1", authorizationDigest: digest, evidenceDigest: evidence(digest) };
101
+ },
102
+ verifyRecorded(authorization, record) {
103
+ const digest = digestCanonical(authorization, sha256Digest);
104
+ const expected = evidence(digest);
105
+ return record.verifierId === "forge-local-adaptive-popup/v1" && record.authorizationDigest === digest &&
106
+ typeof record.evidenceDigest === "string" && record.evidenceDigest.length === expected.length &&
107
+ timingSafeEqual(Buffer.from(record.evidenceDigest), Buffer.from(expected));
108
+ },
109
+ };
110
+ }
111
+
112
+ function escapeHtml(value: string): string {
113
+ return value.replace(/[&<>"']/gu, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!);
114
+ }
115
+
116
+ /** Visible browser decision bound to the exact two inputs and local repository. */
117
+ export async function requestAdaptiveApproval(view: Parameters<Approval>[0]): ReturnType<Approval> {
118
+ const token = randomBytes(32).toString("hex");
119
+ let origin = "";
120
+ let resolveDecision!: (decision: "approved" | "rejected") => void;
121
+ let rejectDecision!: (error: Error) => void;
122
+ const decision = new Promise<"approved" | "rejected">((resolve, reject) => {
123
+ resolveDecision = resolve; rejectDecision = reject;
124
+ });
125
+ let decided = false;
126
+ const server = createServer((request, response) => {
127
+ response.setHeader("Cache-Control", "no-store");
128
+ response.setHeader("Referrer-Policy", "no-referrer");
129
+ response.setHeader("X-Content-Type-Options", "nosniff");
130
+ response.setHeader("X-Frame-Options", "DENY");
131
+ response.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
132
+ if (request.headers.host !== origin.slice(7)) return void response.writeHead(400).end();
133
+ if (request.method === "GET" && request.url === `/${token}` && !decided) {
134
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
135
+ return void response.writeHead(200).end(`<!doctype html><html><meta charset="utf-8"><title>Forge adaptive approval</title><style>body{font:16px system-ui;max-width:700px;margin:4rem auto;padding:1rem}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#eee;padding:1rem}button{padding:.7rem 1rem;margin-right:1rem}</style><h1>Approve fixed local data workers</h1><p>Two bounded Node processes will digest the text below. No model, shell task, network request, or repository edit is selected by these inputs.</p><p>Repository: ${escapeHtml(view.repositoryRoot)}</p><p>Run: ${escapeHtml(view.id)}</p><p>Selected data profile: ${escapeHtml(view.profile ? `${view.profile.channel} / ${view.profile.versionId}` : "none")}</p><h2>Inventory</h2><pre>${escapeHtml(view.input.inventory)}</pre><h2>Constraints</h2><pre>${escapeHtml(view.input.constraints)}</pre><p>Proposal digest: ${escapeHtml(view.digest)}</p><form method="post" action="/decision/${token}"><input type="hidden" name="digest" value="${escapeHtml(view.digest)}"><button name="decision" value="approved">Approve</button><button name="decision" value="rejected">Reject</button></form>`);
136
+ }
137
+ const sameOrigin = request.headers.origin === origin ||
138
+ (request.headers.origin === "null" && request.headers["sec-fetch-site"] === "same-origin");
139
+ if (request.method !== "POST" || request.url !== `/decision/${token}` || decided || !sameOrigin ||
140
+ request.headers["content-type"] !== "application/x-www-form-urlencoded") return void response.writeHead(403).end();
141
+ let body = "";
142
+ request.on("data", (chunk: Buffer) => { body += chunk.toString("utf8"); if (body.length > 2048) request.destroy(); });
143
+ request.on("end", () => {
144
+ const values = new URLSearchParams(body);
145
+ const selected = values.get("decision");
146
+ if (values.size !== 2 || values.get("digest") !== view.digest ||
147
+ (selected !== "approved" && selected !== "rejected")) return void response.writeHead(400).end();
148
+ decided = true;
149
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end("Decision recorded. You may close this window.");
150
+ response.once("finish", () => resolveDecision(selected));
151
+ });
152
+ });
153
+ await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
154
+ origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
155
+ const timer = setTimeout(() => rejectDecision(new Error("Local approval timed out")), 300_000);
156
+ try {
157
+ const command = process.platform === "win32" ? "explorer.exe" : process.platform === "darwin" ? "open" : "xdg-open";
158
+ await new Promise<void>((resolve, reject) => {
159
+ const child = spawn(command, [`${origin}/${token}`], { detached: true, stdio: "ignore", windowsHide: true });
160
+ child.once("error", reject); child.once("spawn", () => { child.unref(); resolve(); });
161
+ });
162
+ return await decision;
163
+ } finally {
164
+ clearTimeout(timer); server.closeAllConnections();
165
+ await new Promise<void>((resolve) => server.close(() => resolve()));
166
+ }
167
+ }
168
+
169
+ export class LocalAdaptiveService {
170
+ private readonly approvals = new Set<Digest>();
171
+ private readonly verifier: OwnerAuthorizationVerifier;
172
+ private readonly control: LocalControlStore;
173
+ private readonly recordsDirectory: string;
174
+ private readonly lockPath: string;
175
+
176
+ private constructor(private readonly repositoryRoot: string, adapter: Awaited<ReturnType<typeof createPgliteAdapter>>,
177
+ private readonly ownerKey: Buffer, private readonly approval: Approval) {
178
+ this.verifier = ownerVerifier(ownerKey, this.approvals);
179
+ this.control = new LocalControlStore({ adapter, clock, ownerAuthorizationVerifier: this.verifier, resourceDefinitions: WORKERS });
180
+ this.recordsDirectory = localFabricPath(repositoryRoot, "adaptive-runs");
181
+ this.lockPath = localFabricPath(repositoryRoot, "adaptive-lock");
182
+ mkdirSync(this.recordsDirectory, { recursive: true });
183
+ }
184
+
185
+ static async open(repositoryRoot: string, approval: Approval = requestAdaptiveApproval): Promise<LocalAdaptiveService> {
186
+ const root = realpathSync(repositoryRoot);
187
+ const keyPath = localFabricPath(root, "adaptive-owner-key");
188
+ mkdirSync(localFabricPath(root), { recursive: true });
189
+ if (!existsSync(keyPath)) writeFileSync(keyPath, randomBytes(32), { flag: "wx", mode: 0o600 });
190
+ const key = readFileSync(keyPath);
191
+ if (key.length !== 32) throw new AgentFabricError("AF_INVALID_STATE", "Invalid adaptive owner key");
192
+ const adapter = await createPgliteAdapter(localFabricPath(root, "adaptive-pglite"));
193
+ return new LocalAdaptiveService(root, adapter, key, approval);
194
+ }
195
+
196
+ private path(id: string): string {
197
+ if (!/^[a-f0-9]{32}$/u.test(id)) throw new AgentFabricError("AF_INVALID_STATE", "Invalid adaptive run id");
198
+ return localFabricPath(this.repositoryRoot, "adaptive-runs", `${id}.json`);
199
+ }
200
+
201
+ private approvalReceipt(record: AdaptiveRecord): Digest {
202
+ return `sha256:${createHmac("sha256", this.ownerKey).update("forge-local-adaptive-input/v1:")
203
+ .update(JSON.stringify({ id: record.id, repositoryRoot: record.repositoryRoot,
204
+ inputDigest: record.inputDigest, profile: record.profile,
205
+ approvedAt: record.approvedAt })).digest("hex")}`;
206
+ }
207
+
208
+ private resultReceipt(record: AdaptiveRecord): Digest {
209
+ return `sha256:${createHmac("sha256", this.ownerKey).update("forge-local-adaptive-result/v1:")
210
+ .update(JSON.stringify({ id: record.id, inputDigest: record.inputDigest,
211
+ profile: record.profile ?? null, result: record.result })).digest("hex")}`;
212
+ }
213
+
214
+ private async assertProfileBinding(record: AdaptiveRecord): Promise<void> {
215
+ if (!record.profile) return;
216
+ const evolution = await LocalEvolutionService.open(this.repositoryRoot);
217
+ try {
218
+ const binding = await evolution.registry.getAttempt(`adaptive:${record.id}`);
219
+ const selected = await evolution.loadSelected(LOCAL_ADAPTIVE_EXTENSION_KEY, record.profile.channel);
220
+ assertLocalAdaptiveInputs(parseLocalAdaptiveInputProfile(selected.artifact),
221
+ record.input.inventory, record.input.constraints);
222
+ if (!binding || binding.versionId !== record.profile.versionId ||
223
+ binding.extensionKey !== LOCAL_ADAPTIVE_EXTENSION_KEY ||
224
+ binding.channel !== record.profile.channel || selected.versionId !== record.profile.versionId) {
225
+ throw new AgentFabricError("AF_CONFLICT", "Approved adaptive profile version is no longer selected");
226
+ }
227
+ } finally { await evolution.close(); }
228
+ }
229
+
230
+ private async locked<T>(operation: () => Promise<T>): Promise<T> {
231
+ let descriptor: number;
232
+ try { descriptor = openSync(this.lockPath, "wx", 0o600); }
233
+ catch { throw new AgentFabricError("AF_CONFLICT", "Adaptive owner is busy; if a process crashed, inspect status before manual lock recovery"); }
234
+ try { return await operation(); }
235
+ finally { closeSync(descriptor); unlinkSync(this.lockPath); }
236
+ }
237
+
238
+ async propose(value: unknown, channel?: EvolutionChannel): Promise<AdaptiveStatus> {
239
+ const input = validateInput(value);
240
+ return this.locked(async () => {
241
+ const record: AdaptiveRecord = { schemaVersion: 1, id: randomBytes(16).toString("hex"),
242
+ repositoryRoot: this.repositoryRoot, input, inputDigest: digestCanonical(input, sha256Digest),
243
+ phase: "proposed", createdAt: clock.now() };
244
+ if (channel) {
245
+ const evolution = await LocalEvolutionService.open(this.repositoryRoot);
246
+ try {
247
+ const selected = await evolution.resolveSelectedLocalAdaptiveInputs(
248
+ channel, `adaptive:${record.id}`, input.inventory, input.constraints);
249
+ record.profile = { channel, versionId: selected.versionId };
250
+ } finally { await evolution.close(); }
251
+ }
252
+ writeRecord(this.path(record.id), record);
253
+ return this.status(record.id);
254
+ });
255
+ }
256
+
257
+ async review(id: string): Promise<AdaptiveStatus> {
258
+ return this.locked(async () => {
259
+ const path = this.path(id);
260
+ const record = readRecord(path);
261
+ if (record.phase !== "proposed") throw new AgentFabricError("AF_CONFLICT", "Adaptive run is not proposed");
262
+ await this.assertProfileBinding(record);
263
+ const decision = await this.approval({ repositoryRoot: this.repositoryRoot, id, input: record.input,
264
+ digest: digestCanonical({ id, repositoryRoot: this.repositoryRoot, inputDigest: record.inputDigest,
265
+ profile: record.profile ?? null }, sha256Digest),
266
+ ...(record.profile ? { profile: record.profile } : {}) });
267
+ if (decision !== "approved" && decision !== "rejected") throw new AgentFabricError("AF_INVALID_STATE", "Invalid owner decision");
268
+ record.phase = decision === "approved" ? "approved" : "rejected";
269
+ if (decision === "approved") {
270
+ record.approvedAt = clock.now();
271
+ record.approvalReceipt = this.approvalReceipt(record);
272
+ }
273
+ writeRecord(path, record);
274
+ return this.status(id);
275
+ });
276
+ }
277
+
278
+ async run(id: string, signal?: AbortSignal): Promise<AdaptiveStatus> {
279
+ return this.locked(async () => {
280
+ const path = this.path(id);
281
+ const record = readRecord(path);
282
+ if (record.phase !== "approved") throw new AgentFabricError("AF_CONFLICT", "Adaptive run requires unused owner approval");
283
+ if (!record.approvedAt || clock.now() - record.approvedAt > 300_000) {
284
+ throw new AgentFabricError("AF_GRANT_REJECTED", "Adaptive owner approval expired");
285
+ }
286
+ const expectedReceipt = this.approvalReceipt(record);
287
+ if (!record.approvalReceipt || record.approvalReceipt.length !== expectedReceipt.length ||
288
+ !timingSafeEqual(Buffer.from(record.approvalReceipt), Buffer.from(expectedReceipt))) {
289
+ throw new AgentFabricError("AF_GRANT_REJECTED", "Adaptive owner approval does not match inputs");
290
+ }
291
+ await this.assertProfileBinding(record);
292
+ record.phase = "running";
293
+ record.startedAt = clock.now();
294
+ writeRecord(path, record); // A crash from here is uncertain and never reruns workers.
295
+ const identity = ids(id);
296
+ const now = clock.now();
297
+ const authorization: OwnerAuthorization = {
298
+ authorizationId: identity.authorization, principalId: "owner:local", rootExecutionId: identity.root,
299
+ goalIds: [identity.goal], subjectIds: ["fabric.local.coordinator", "fabric.local.inventory", "fabric.local.constraints"],
300
+ capabilities: ["fabric.local.read"], sourceIds: ["source:owner-input"], targetIds: ["target:adaptive-digest"],
301
+ effectClasses: ["read"], notBefore: now, expiresAt: now + 30_000, maximumAttempts: 3,
302
+ maximumDelegationDepth: 1, resourceCeilings: { workers: 2 },
303
+ };
304
+ const goal: GoalContract = {
305
+ goalId: identity.goal, revision: 1, authorityInvocationId: identity.authorization,
306
+ objectives: ["Digest owner supplied bounded data with two fixed workers"], nonObjectives: ["Run task instructions"],
307
+ acceptanceCriteria: ["Two authoritative child digests and one join",
308
+ ...(record.profile ? [`Selected data profile ${record.profile.versionId}`] : [])],
309
+ allowedEffectClasses: ["read"], prohibitedEffectClasses: ["consequential"],
310
+ sourceBoundary: { sourceIds: ["source:owner-input"], allowExpansion: false },
311
+ };
312
+ try {
313
+ this.approvals.add(digestCanonical(authorization, sha256Digest));
314
+ await this.control.transition(identity.root, (conductor) => {
315
+ conductor.registerOwnerAuthorization(authorization);
316
+ conductor.registerGoal(goal);
317
+ const revision = compileLocalAdaptiveRevision(identity.root, identity.goal, identity.revision);
318
+ conductor.activatePlan(revision, null);
319
+ conductor.registerGrant({ grantId: identity.grant, rootAuthorizationId: identity.authorization,
320
+ subjectId: "fabric.local.coordinator", parentGrantId: null, capabilities: ["fabric.local.read"],
321
+ sourceIds: ["source:owner-input"], targetIds: ["target:adaptive-digest"], effectClasses: ["read"],
322
+ notBefore: now, expiresAt: now + 20_000, maximumAttempts: 3,
323
+ delegationDepthRemaining: 1, resourceCeilings: { workers: 2 } });
324
+ new LocalAdaptiveHarness({ conductor, clock, rootExecutionId: identity.root, revisionId: identity.revision,
325
+ parentGrantId: identity.grant, sourceId: "source:owner-input", targetId: "target:adaptive-digest",
326
+ ...record.input }).prepare();
327
+ });
328
+ const executed = await this.control.runExternal(identity.root, async (conductor) => {
329
+ const harness = new LocalAdaptiveHarness({ conductor, clock, rootExecutionId: identity.root,
330
+ revisionId: identity.revision, parentGrantId: identity.grant, sourceId: "source:owner-input",
331
+ targetId: "target:adaptive-digest", ...record.input });
332
+ harness.resumePrepared();
333
+ return harness.runPrepared(signal);
334
+ });
335
+ // Sign the JSON representation that will be written to disk. PIDs are
336
+ // local observations; child outcomes are also checked against P0a below.
337
+ record.result = JSON.parse(JSON.stringify(executed.result)) as LocalAdaptiveProcessResult;
338
+ record.resultReceipt = this.resultReceipt(record);
339
+ record.phase = executed.result.join.status === "succeeded" ? "succeeded" : "blocked";
340
+ } catch (error) {
341
+ record.phase = "uncertain";
342
+ record.failure = error instanceof Error ? error.message : String(error);
343
+ } finally {
344
+ this.approvals.clear();
345
+ record.finishedAt = clock.now();
346
+ writeRecord(path, record);
347
+ }
348
+ return this.status(id);
349
+ });
350
+ }
351
+
352
+ async status(id: string): Promise<AdaptiveStatus> {
353
+ const record = readRecord(this.path(id));
354
+ if (record.id !== id || record.repositoryRoot !== this.repositoryRoot) {
355
+ throw new AgentFabricError("AF_INVALID_STATE", "Adaptive record belongs to another repository");
356
+ }
357
+ const events = await this.control.readAll(ids(id).root);
358
+ const state = replayControlState(events, { ownerAuthorizationVerifier: this.verifier, resourceDefinitions: WORKERS });
359
+ const outcomes = Object.values(state.outcomes);
360
+ const join = state.outcomes[`attempt:${ids(id).revision}:join`];
361
+ if (record.result) {
362
+ const expected = this.resultReceipt(record);
363
+ if (!record.resultReceipt || record.resultReceipt.length !== expected.length ||
364
+ !timingSafeEqual(Buffer.from(record.resultReceipt), Buffer.from(expected))) {
365
+ throw new AgentFabricError("AF_INVALID_STATE", "Adaptive saved result receipt is invalid");
366
+ }
367
+ for (const role of ["inventory", "constraints"] as const) {
368
+ const saved = record.result.children?.[role];
369
+ const authoritative = state.outcomes[`attempt:${ids(id).revision}:${role}`];
370
+ if (!saved) throw new AgentFabricError("AF_INVALID_STATE", "Adaptive saved child is missing");
371
+ if ((saved.status === "succeeded" || saved.status === "failed") &&
372
+ (!authoritative || digestCanonical(saved, sha256Digest) !== digestCanonical(authoritative, sha256Digest))) {
373
+ throw new AgentFabricError("AF_INVALID_STATE", "Adaptive saved child differs from authoritative outcome");
374
+ }
375
+ if (saved.status !== "succeeded" && saved.status !== "failed" && authoritative) {
376
+ throw new AgentFabricError("AF_INVALID_STATE", "Adaptive saved child differs from authoritative outcome");
377
+ }
378
+ }
379
+ }
380
+ const phase: Phase = join?.status === "succeeded" ? "succeeded" :
381
+ record.phase === "running" ? "uncertain" : record.phase;
382
+ if (record.phase === "succeeded" && !join) throw new AgentFabricError("AF_INVALID_STATE", "Adaptive result has no authoritative join");
383
+ if (record.result?.join.status === "succeeded" &&
384
+ (!join || record.result.join.outcome.reportDigest !== join.reportDigest ||
385
+ record.result.join.outcome.resultDigest !== join.resultDigest)) {
386
+ throw new AgentFabricError("AF_INVALID_STATE", "Adaptive saved result differs from authoritative join");
387
+ }
388
+ return { id, phase, inputDigest: record.inputDigest,
389
+ ...(record.profile ? { profile: record.profile } : {}),
390
+ ...(record.result ? { result: record.result } : {}),
391
+ ...(record.failure ? { failure: record.failure } : {}),
392
+ journal: { events: events.length, childOutcomes: outcomes.filter((outcome) => outcome.attemptId.endsWith(":inventory") || outcome.attemptId.endsWith(":constraints")).length,
393
+ ...(join ? { joinOutcome: { status: join.status, resultDigest: join.resultDigest, reportDigest: join.reportDigest } } : {}) },
394
+ };
395
+ }
396
+
397
+ async close(): Promise<void> { await this.control.close(); }
398
+ }
@@ -0,0 +1,146 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { tmpdir } from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+ import { digestCanonical, sha256Digest } from "./canonical.ts";
5
+ import type {
6
+ AdapterManifest, AdapterOutcomeResult, AdapterStartResult, AgentAdapter,
7
+ AttemptExecutionPermit, Clock, Digest, RuntimeObservation, WorkerResultReport,
8
+ } from "./types.ts";
9
+ import type { LocalAdaptiveRole } from "./local-adaptive-harness.ts";
10
+
11
+ const WORKER_PATH = fileURLToPath(new URL("./local-adaptive-digest-worker.mjs", import.meta.url));
12
+ const MAX_OUTPUT_BYTES = 512;
13
+ const MAX_WALL_MS = 1_000;
14
+
15
+ /** Runs one fixed digest worker. Node process isolation here is a bound on this
16
+ * trusted worker program, not a sandbox for arbitrary user or model code. */
17
+ export class LocalAdaptiveProcessAdapter implements AgentAdapter {
18
+ private child: ChildProcessWithoutNullStreams | undefined;
19
+ private outcome: Promise<AdapterOutcomeResult> | undefined;
20
+ private aborted = false;
21
+ private timedOut = false;
22
+ private oversized = false;
23
+ private reportedPid: number | undefined;
24
+
25
+ constructor(
26
+ private readonly role: LocalAdaptiveRole,
27
+ private readonly input: string,
28
+ private readonly clock: Clock,
29
+ private readonly signal?: AbortSignal,
30
+ ) {}
31
+
32
+ get pid(): number | undefined { return this.reportedPid; }
33
+
34
+ manifest(): AdapterManifest {
35
+ return { adapterId: "fabric.local.digest-process/v1", version: "1",
36
+ capabilities: ["fabric.local.read"], supportsCancellation: true,
37
+ supportsObservation: false };
38
+ }
39
+
40
+ async startAttempt(permit: AttemptExecutionPermit): Promise<AdapterStartResult> {
41
+ if (this.child || this.outcome) return { status: "unknown", reason: "worker_already_started" };
42
+ if (this.signal?.aborted) return { status: "unknown", reason: "cancelled_before_start" };
43
+ let child: ChildProcessWithoutNullStreams;
44
+ try {
45
+ child = spawn(process.execPath, [WORKER_PATH], {
46
+ cwd: tmpdir(), env: {}, stdio: ["pipe", "pipe", "pipe"],
47
+ windowsHide: true, shell: false,
48
+ });
49
+ } catch {
50
+ return { status: "unknown", reason: "worker_spawn_failed" };
51
+ }
52
+ this.child = child;
53
+ let output = "";
54
+ const deadline = setTimeout(() => {
55
+ this.timedOut = true;
56
+ child.kill();
57
+ }, MAX_WALL_MS);
58
+ const abort = () => {
59
+ this.aborted = true;
60
+ child.kill();
61
+ };
62
+ this.signal?.addEventListener("abort", abort, { once: true });
63
+ child.stdout.setEncoding("utf8");
64
+ child.stdin.on("error", () => { /* Exit is handled by the close event. */ });
65
+ child.stdout.on("data", (chunk: string) => {
66
+ output += chunk;
67
+ if (Buffer.byteLength(output, "utf8") > MAX_OUTPUT_BYTES) {
68
+ this.oversized = true;
69
+ child.kill();
70
+ }
71
+ });
72
+ // A worker may print diagnostics, but they are never surfaced as authority.
73
+ child.stderr.resume();
74
+ this.outcome = new Promise<AdapterOutcomeResult>((resolve) => {
75
+ child.once("close", (code) => {
76
+ clearTimeout(deadline);
77
+ this.signal?.removeEventListener("abort", abort);
78
+ if (this.aborted || this.timedOut || this.oversized) {
79
+ resolve({ status: "unknown", reason: this.aborted ? "worker_cancelled" :
80
+ this.timedOut ? "worker_timeout" : "worker_output_oversized" });
81
+ return;
82
+ }
83
+ if (code !== 0) {
84
+ resolve({ status: "reported", report: this.report(permit, "failed",
85
+ digestCanonical({ role: this.role, failure: "worker_exit" }, sha256Digest)) });
86
+ return;
87
+ }
88
+ let parsed: unknown;
89
+ try { parsed = JSON.parse(output); } catch {
90
+ resolve({ status: "unknown", reason: "worker_output_invalid" });
91
+ return;
92
+ }
93
+ const value = parsed as Record<string, unknown>;
94
+ const expected = digestCanonical({ role: this.role, input: this.input }, sha256Digest);
95
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
96
+ Object.keys(value).sort().join(",") !== "pid,resultDigest,role" ||
97
+ value.pid !== child.pid || value.role !== this.role || value.resultDigest !== expected) {
98
+ resolve({ status: "unknown", reason: "worker_digest_mismatch" });
99
+ return;
100
+ }
101
+ this.reportedPid = child.pid;
102
+ resolve({ status: "reported", report: this.report(permit, "succeeded", expected) });
103
+ });
104
+ });
105
+ const started = await new Promise<boolean>((resolve) => {
106
+ child.once("spawn", () => resolve(true));
107
+ child.once("error", () => resolve(false));
108
+ });
109
+ if (!started) {
110
+ child.kill();
111
+ return { status: "unknown", reason: "worker_spawn_failed" };
112
+ }
113
+ child.stdin.end(JSON.stringify({ role: this.role, input: this.input }));
114
+ return { status: "started", report: {
115
+ startupReportId: `startup:${permit.attemptId}`, attemptId: permit.attemptId,
116
+ observedSpecDigest: permit.effectiveRunSpecDigest, startedAt: this.clock.now(),
117
+ } };
118
+ }
119
+
120
+ async collectOutcome(_attemptId: string): Promise<AdapterOutcomeResult> {
121
+ return this.outcome ?? { status: "unknown", reason: "worker_not_started" };
122
+ }
123
+
124
+ async observeAttempt(_attemptId: string): Promise<readonly RuntimeObservation[]> { return []; }
125
+
126
+ async requestCancellation(_attemptId: string): Promise<{ acknowledged: boolean }> {
127
+ this.aborted = true;
128
+ return { acknowledged: this.child?.kill() ?? false };
129
+ }
130
+
131
+ async observeTermination(_attemptId: string): Promise<"terminated" | "running" | "unknown"> {
132
+ if (!this.child) return "unknown";
133
+ return this.child.exitCode === null ? "running" : "terminated";
134
+ }
135
+
136
+ private report(permit: AttemptExecutionPermit, status: "succeeded" | "failed", resultDigest: Digest): WorkerResultReport {
137
+ return {
138
+ reportId: `report:${permit.attemptId}`, attemptId: permit.attemptId,
139
+ permitId: permit.permitId, intentId: permit.intentId,
140
+ planRevisionId: permit.planRevisionId,
141
+ effectiveRunSpecDigest: permit.effectiveRunSpecDigest,
142
+ fencingToken: permit.fencingToken, status, resultDigest,
143
+ evidenceDigests: [], reportedAt: this.clock.now(),
144
+ };
145
+ }
146
+ }