forgeos 0.1.0-alpha.63 → 0.1.0-alpha.64

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 (63) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/docs/agent-fabric.md +252 -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 +18 -0
  20. package/src/forge/agent-fabric/journal.ts +116 -0
  21. package/src/forge/agent-fabric/local-approval-window.ts +169 -0
  22. package/src/forge/agent-fabric/local-coding-worker.ts +190 -0
  23. package/src/forge/agent-fabric/local-control-store.ts +239 -0
  24. package/src/forge/agent-fabric/local-paths.ts +18 -0
  25. package/src/forge/agent-fabric/local-task-contract.ts +142 -0
  26. package/src/forge/agent-fabric/local-task-inbox.ts +199 -0
  27. package/src/forge/agent-fabric/local-task-server.ts +207 -0
  28. package/src/forge/agent-fabric/local-task-service.ts +494 -0
  29. package/src/forge/agent-fabric/p0a.ts +104 -0
  30. package/src/forge/agent-fabric/p0b-model-adapter.ts +404 -0
  31. package/src/forge/agent-fabric/planning.ts +155 -0
  32. package/src/forge/agent-fabric/reducer.ts +863 -0
  33. package/src/forge/agent-fabric/resource-ledger.ts +266 -0
  34. package/src/forge/agent-fabric/serialized-local-adapter.ts +60 -0
  35. package/src/forge/agent-fabric/types.ts +424 -0
  36. package/src/forge/agent-fabric/validation.ts +500 -0
  37. package/src/forge/agent-memory/bridge.ts +82 -52
  38. package/src/forge/agent-memory/mcp.ts +59 -9
  39. package/src/forge/agent-memory/sources/codex-hook-runner.mjs +12 -113
  40. package/src/forge/cli/commands.ts +4 -0
  41. package/src/forge/cli/fabric.ts +83 -0
  42. package/src/forge/cli/main.ts +7 -0
  43. package/src/forge/cli/new.ts +16 -4
  44. package/src/forge/cli/parse.ts +21 -0
  45. package/src/forge/cli/verify.ts +4 -1
  46. package/src/forge/compiler/app-graph/tsconfig-hash.ts +41 -4
  47. package/src/forge/compiler/package-graph/dts-extractor.ts +14 -1
  48. package/src/forge/impact/index.ts +18 -21
  49. package/src/forge/runtime/ai/providers.ts +19 -4
  50. package/src/forge/version.ts +1 -1
  51. package/templates/agent-workroom/package.json +1 -0
  52. package/templates/agent-workroom/pnpm-workspace.yaml +2 -0
  53. package/templates/b2b-support-web/package.json +1 -0
  54. package/templates/b2b-support-web/pnpm-workspace.yaml +2 -0
  55. package/templates/b2b-support-web/web/package.json +1 -1
  56. package/templates/minimal-web/package.json +1 -0
  57. package/templates/minimal-web/pnpm-workspace.yaml +2 -0
  58. package/templates/nuxt-web/.yarnrc.yml +1 -0
  59. package/templates/nuxt-web/package.json +1 -0
  60. package/templates/nuxt-web/pnpm-workspace.yaml +2 -0
  61. package/templates/nuxt-web/web/package.json +1 -0
  62. package/templates/vendor-access/package.json +1 -0
  63. package/templates/vendor-access/pnpm-workspace.yaml +2 -0
@@ -0,0 +1,190 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { sha256Digest } from "./canonical.ts";
5
+ import { AgentFabricError } from "./errors.ts";
6
+ import type { LocalCodingTaskProposal } from "./local-task-contract.ts";
7
+ import type { Digest } from "./types.ts";
8
+ import { localFabricPath } from "./local-paths.ts";
9
+
10
+ function git(root: string, args: readonly string[], maxBuffer = 128 * 1024): string {
11
+ try {
12
+ return execFileSync("git", [...args], {
13
+ cwd: root, encoding: "utf8", windowsHide: true,
14
+ timeout: 10_000, maxBuffer, stdio: ["ignore", "pipe", "ignore"],
15
+ });
16
+ } catch {
17
+ throw new AgentFabricError("AF_INVALID_STATE", `Git operation failed: ${args[0] ?? "unknown"}`);
18
+ }
19
+ }
20
+
21
+ export function buildLocalCodingContext(repositoryRoot: string, task: Readonly<LocalCodingTaskProposal>): string {
22
+ const sources = task.sourcePaths.map((path) => ({
23
+ path,
24
+ content: git(repositoryRoot, ["show", `${task.baseCommit}:${path}`], task.limits.maximumContextBytes + 1),
25
+ }));
26
+ const content = JSON.stringify({ baseCommit: task.baseCommit, sources });
27
+ if (Buffer.byteLength(content, "utf8") > task.limits.maximumContextBytes) {
28
+ throw new AgentFabricError("AF_INVALID_STATE", "Pinned source context exceeds the approved byte limit");
29
+ }
30
+ return content;
31
+ }
32
+
33
+ function validateModelFiles(text: string, task: Readonly<LocalCodingTaskProposal>): readonly { path: string; content: string }[] {
34
+ if (Buffer.byteLength(text, "utf8") > task.limits.maximumPatchBytes) {
35
+ throw new AgentFabricError("AF_INVALID_STATE", "Model output exceeds the approved patch byte limit");
36
+ }
37
+ let parsed: unknown;
38
+ const fenced = /^```(?:json)?\r?\n([\s\S]*?)\r?\n```$/u.exec(text.trim());
39
+ try { parsed = JSON.parse(fenced ? fenced[1]! : text); } catch {
40
+ throw new AgentFabricError("AF_INVALID_STATE", "Model output is not strict JSON");
41
+ }
42
+ if (!parsed || typeof parsed !== "object") {
43
+ throw new AgentFabricError("AF_INVALID_STATE", "Model output is not a file proposal");
44
+ }
45
+ // Small local models sometimes emit the files array directly. Both forms
46
+ // undergo the same strict path, content, count, and size checks below.
47
+ const root = parsed as Record<string, unknown>;
48
+ const rootKeys = Object.keys(root).sort().join(",");
49
+ const fileMap = !Array.isArray(parsed) && rootKeys !== "files" &&
50
+ rootKeys !== "files,schemaVersion" && Object.keys(root).every((path) =>
51
+ task.writablePaths.includes(path) && typeof root[path] === "string");
52
+ const proposedFiles = Array.isArray(parsed) ? parsed : fileMap
53
+ ? Object.entries(root).map(([path, content]) => ({ path, content })) : root.files;
54
+ if ((!Array.isArray(parsed) && !fileMap &&
55
+ (rootKeys !== "files" &&
56
+ (rootKeys !== "files,schemaVersion" ||
57
+ (root.schemaVersion !== 1 && root.schemaVersion !== "1")))) ||
58
+ !Array.isArray(proposedFiles) || proposedFiles.length < 1 || proposedFiles.length > task.writablePaths.length) {
59
+ throw new AgentFabricError("AF_INVALID_STATE", "Model file proposal has unsupported structure");
60
+ }
61
+ const seen = new Set<string>();
62
+ return proposedFiles.map((item: unknown) => {
63
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
64
+ throw new AgentFabricError("AF_INVALID_STATE", "Invalid model file proposal");
65
+ }
66
+ const file = item as Record<string, unknown>;
67
+ if (Object.keys(file).sort().join(",") !== "content,path" ||
68
+ typeof file.path !== "string" || !task.writablePaths.includes(file.path) ||
69
+ seen.has(file.path.toLowerCase()) || typeof file.content !== "string" ||
70
+ file.content.includes("\u0000")) {
71
+ throw new AgentFabricError("AF_INVALID_STATE", "Model proposed an unauthorized file or content");
72
+ }
73
+ seen.add(file.path.toLowerCase());
74
+ return { path: file.path, content: file.content };
75
+ });
76
+ }
77
+
78
+ function checkedTarget(worktreeRoot: string, path: string): string {
79
+ const target = resolve(worktreeRoot, path);
80
+ const prefix = `${worktreeRoot}${process.platform === "win32" ? "\\" : "/"}`;
81
+ if (!target.startsWith(prefix)) {
82
+ throw new AgentFabricError("AF_INVALID_STATE", "Model path escapes the isolated checkout");
83
+ }
84
+ let cursor = worktreeRoot;
85
+ for (const segment of path.split("/")) {
86
+ cursor = join(cursor, segment);
87
+ if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink()) {
88
+ throw new AgentFabricError("AF_INVALID_STATE", "Model path traverses a symbolic link");
89
+ }
90
+ }
91
+ return target;
92
+ }
93
+
94
+ export interface LocalPatchEvidence {
95
+ worktreeRoot: string;
96
+ baseCommit: string;
97
+ changedPaths: readonly string[];
98
+ diffDigest: Digest;
99
+ diffBytes: number;
100
+ diffPath: string;
101
+ verification: "diff_check_passed" | "diff_check_failed";
102
+ }
103
+
104
+ /** Recheck the live checkout before showing or accepting its saved diff. */
105
+ export function verifyLocalPatchEvidence(patch: LocalPatchEvidence): void {
106
+ const canonical = realpathSync(patch.worktreeRoot);
107
+ const checkoutRoot = realpathSync(git(canonical, ["rev-parse", "--show-toplevel"]).trim());
108
+ if (checkoutRoot !== canonical || git(canonical, ["rev-parse", "HEAD"]).trim() !== patch.baseCommit) {
109
+ throw new AgentFabricError("AF_CONFLICT", "Approved patch checkout moved from its recorded base");
110
+ }
111
+ if (git(canonical, ["diff", "--cached", "--binary"]).length > 0 ||
112
+ git(canonical, ["ls-files", "--others", "--exclude-standard"]).length > 0) {
113
+ throw new AgentFabricError("AF_CONFLICT", "Patch checkout has unrecorded files or staged changes");
114
+ }
115
+ const diff = git(canonical, ["diff", "--binary"], patch.diffBytes + 1);
116
+ if (sha256Digest(diff) !== patch.diffDigest ||
117
+ sha256Digest(readFileSync(patch.diffPath, "utf8")) !== patch.diffDigest) {
118
+ throw new AgentFabricError("AF_CONFLICT", "Live patch no longer matches its recorded diff");
119
+ }
120
+ }
121
+
122
+ /** Apply a model's file proposal only inside a fresh checkout of the pinned commit. */
123
+ export function materializeLocalCodingPatch(
124
+ repositoryRoot: string,
125
+ taskId: string,
126
+ task: Readonly<LocalCodingTaskProposal>,
127
+ modelText: string,
128
+ ): LocalPatchEvidence {
129
+ const files = validateModelFiles(modelText, task);
130
+ const worktreeRoot = localFabricPath(repositoryRoot, "worktrees", taskId.replace(/^task:/u, ""));
131
+ const existing = existsSync(worktreeRoot);
132
+ if (!existing) {
133
+ mkdirSync(dirname(worktreeRoot), { recursive: true });
134
+ git(repositoryRoot, ["worktree", "add", "--detach", "--", worktreeRoot, task.baseCommit]);
135
+ }
136
+ const canonical = realpathSync(worktreeRoot);
137
+ const reportedRoot = realpathSync(git(canonical, ["rev-parse", "--show-toplevel"]).trim());
138
+ if ((process.platform === "win32" ? reportedRoot.toLowerCase() : reportedRoot) !==
139
+ (process.platform === "win32" ? canonical.toLowerCase() : canonical)) {
140
+ throw new AgentFabricError("AF_INVALID_STATE", "Isolated checkout root is not its own Git worktree");
141
+ }
142
+ if (git(canonical, ["rev-parse", "HEAD"]).trim() !== task.baseCommit) {
143
+ throw new AgentFabricError("AF_INVALID_STATE", "Isolated checkout is not at the approved base commit");
144
+ }
145
+ for (const file of files) {
146
+ const target = checkedTarget(canonical, file.path);
147
+ if (existing) {
148
+ if (!existsSync(target) || readFileSync(target, "utf8") !== file.content) {
149
+ throw new AgentFabricError("AF_CONFLICT", "Existing checkout differs from the recorded model result");
150
+ }
151
+ } else {
152
+ mkdirSync(dirname(target), { recursive: true });
153
+ writeFileSync(target, file.content, { flag: "w" });
154
+ }
155
+ }
156
+ git(canonical, ["add", "-N", "--", ...files.map((file) => file.path)]);
157
+ const diff = git(canonical, ["diff", "--binary", "--", ...files.map((file) => file.path)],
158
+ task.limits.maximumPatchBytes + 1);
159
+ const diffBytes = Buffer.byteLength(diff, "utf8");
160
+ if (diffBytes === 0 || diffBytes > task.limits.maximumPatchBytes) {
161
+ throw new AgentFabricError("AF_INVALID_STATE", "Resulting diff is empty or exceeds the approved patch limit");
162
+ }
163
+ const changedPaths = git(canonical, ["diff", "--name-only", "--", ...files.map((file) => file.path)])
164
+ .trim().split(/\r?\n/u).filter(Boolean);
165
+ if (changedPaths.length === 0 || changedPaths.some((path) => !task.writablePaths.includes(path))) {
166
+ throw new AgentFabricError("AF_INVALID_STATE", "Resulting diff contains an unauthorized path");
167
+ }
168
+ const diffPath = localFabricPath(repositoryRoot, "artifacts", `${taskId.replace(/^task:/u, "")}.diff`);
169
+ mkdirSync(dirname(diffPath), { recursive: true });
170
+ if (existsSync(diffPath)) {
171
+ if (readFileSync(diffPath, "utf8") !== diff) {
172
+ throw new AgentFabricError("AF_CONFLICT", "Existing diff artifact differs from the model result");
173
+ }
174
+ } else {
175
+ writeFileSync(diffPath, diff, { flag: "wx" });
176
+ }
177
+ if (sha256Digest(readFileSync(diffPath, "utf8")) !== sha256Digest(diff)) {
178
+ throw new AgentFabricError("AF_INVALID_STATE", "Diff artifact failed readback");
179
+ }
180
+ let verification: LocalPatchEvidence["verification"] = "diff_check_passed";
181
+ try {
182
+ git(canonical, ["diff", "--check", "--", ...files.map((file) => file.path)]);
183
+ } catch {
184
+ verification = "diff_check_failed";
185
+ }
186
+ const patch = { worktreeRoot: canonical, baseCommit: task.baseCommit, changedPaths,
187
+ diffDigest: sha256Digest(diff), diffBytes, diffPath, verification };
188
+ verifyLocalPatchEvidence(patch);
189
+ return patch;
190
+ }
@@ -0,0 +1,239 @@
1
+ import type { DbAdapter } from "../runtime/db/adapter.ts";
2
+ import { sha256Digest, stableStringify } from "./canonical.ts";
3
+ import { AgentFabricError } from "./errors.ts";
4
+ import { ForgeAgentConductor } from "./hardened-conductor.ts";
5
+ import { MemoryControlJournal } from "./journal.ts";
6
+ import { replayControlState } from "./hardened-reducer.ts";
7
+ import { ResourceLedger } from "./resource-ledger.ts";
8
+ import type {
9
+ Clock, ControlEventEnvelope, OwnerAuthorizationVerifier, ResourceDefinition,
10
+ UncommittedControlEvent,
11
+ } from "./types.ts";
12
+
13
+ const CREATE_EVENTS_TABLE = `
14
+ CREATE TABLE IF NOT EXISTS _forge_agent_fabric_control_events (
15
+ root_execution_id TEXT NOT NULL,
16
+ event_sequence INTEGER NOT NULL,
17
+ event_id TEXT NOT NULL,
18
+ idempotency_key TEXT,
19
+ envelope_json TEXT NOT NULL,
20
+ PRIMARY KEY (root_execution_id, event_sequence),
21
+ UNIQUE (root_execution_id, event_id),
22
+ UNIQUE (root_execution_id, idempotency_key)
23
+ )`;
24
+
25
+ export interface LocalControlStoreOptions {
26
+ adapter: DbAdapter;
27
+ clock: Clock;
28
+ ownerAuthorizationVerifier: OwnerAuthorizationVerifier;
29
+ resourceDefinitions?: readonly ResourceDefinition[];
30
+ }
31
+
32
+ export interface LocalControlTransitionResult<T> {
33
+ result: T;
34
+ events: readonly ControlEventEnvelope[];
35
+ }
36
+
37
+ /**
38
+ * Single-process PGlite transaction boundary around the synchronous P0a Conductor.
39
+ * Only trusted server code may call transition; callbacks must have no external effects.
40
+ */
41
+ export class LocalControlStore {
42
+ private tail: Promise<void> = Promise.resolve();
43
+ private readonly definitions: readonly ResourceDefinition[];
44
+ private schemaReady?: Promise<unknown>;
45
+ private closed = false;
46
+ private readonly externalRoots = new Set<string>();
47
+ private readonly externalRuns = new Set<Promise<unknown>>();
48
+
49
+ constructor(private readonly options: LocalControlStoreOptions) {
50
+ if (options.adapter.kind !== "pglite") {
51
+ throw new AgentFabricError("AF_INVALID_STATE", "Local control store requires the PGlite adapter");
52
+ }
53
+ this.definitions = structuredClone(options.resourceDefinitions ?? []);
54
+ }
55
+
56
+ private async serialized<T>(operation: () => Promise<T>): Promise<T> {
57
+ if (this.closed) throw new AgentFabricError("AF_INVALID_STATE", "Local control store is closed");
58
+ const predecessor = this.tail;
59
+ let release!: () => void;
60
+ this.tail = new Promise<void>((resolve) => { release = resolve; });
61
+ await predecessor;
62
+ try {
63
+ this.schemaReady ??= this.options.adapter.query(CREATE_EVENTS_TABLE);
64
+ await this.schemaReady;
65
+ return await operation();
66
+ } finally {
67
+ release();
68
+ }
69
+ }
70
+
71
+ private async load(rootExecutionId: string): Promise<ControlEventEnvelope[]> {
72
+ if (!rootExecutionId || rootExecutionId.length > 128) {
73
+ throw new AgentFabricError("AF_INVALID_STATE", "Invalid root execution identity");
74
+ }
75
+ const result = await this.options.adapter.query(
76
+ `SELECT event_sequence, envelope_json FROM _forge_agent_fabric_control_events
77
+ WHERE root_execution_id = $1 ORDER BY event_sequence`,
78
+ [rootExecutionId],
79
+ );
80
+ const events: ControlEventEnvelope[] = [];
81
+ for (const row of result.rows) {
82
+ if (typeof row.envelope_json !== "string") {
83
+ throw new AgentFabricError("AF_INVALID_EVENT", "Stored control envelope is missing");
84
+ }
85
+ let event: ControlEventEnvelope;
86
+ try {
87
+ event = JSON.parse(row.envelope_json) as ControlEventEnvelope;
88
+ } catch {
89
+ throw new AgentFabricError("AF_INVALID_EVENT", "Stored control envelope is not JSON");
90
+ }
91
+ if (!event || typeof event !== "object" ||
92
+ event.rootExecutionId !== rootExecutionId || event.sequence !== row.event_sequence) {
93
+ throw new AgentFabricError("AF_INVALID_EVENT", "Stored control envelope is outside its stream");
94
+ }
95
+ events.push(event);
96
+ }
97
+ replayControlState(events, {
98
+ ownerAuthorizationVerifier: this.options.ownerAuthorizationVerifier,
99
+ resourceDefinitions: this.definitions,
100
+ });
101
+ return events;
102
+ }
103
+
104
+ async readAll(rootExecutionId: string): Promise<readonly ControlEventEnvelope[]> {
105
+ return this.serialized(async () => structuredClone(await this.load(rootExecutionId)));
106
+ }
107
+
108
+ private hydrate(rootExecutionId: string, before: readonly ControlEventEnvelope[]): {
109
+ journal: MemoryControlJournal;
110
+ conductor: ForgeAgentConductor;
111
+ } {
112
+ const journal = new MemoryControlJournal();
113
+ for (const stored of before) {
114
+ const {
115
+ sequence: _sequence,
116
+ predecessorEventId: _predecessorEventId,
117
+ predecessorEventDigest: _predecessorEventDigest,
118
+ eventDigest: _eventDigest,
119
+ ...uncommitted
120
+ } = stored;
121
+ const restored = journal.append({
122
+ expectedSequence: journal.readAll().length,
123
+ event: uncommitted as UncommittedControlEvent,
124
+ });
125
+ if (stableStringify(restored) !== stableStringify(stored)) {
126
+ throw new AgentFabricError("AF_INVALID_EVENT", "Stored control event changed during hydration");
127
+ }
128
+ }
129
+ const ledger = this.definitions.length > 0
130
+ ? new ResourceLedger(this.definitions)
131
+ : undefined;
132
+ return {
133
+ journal,
134
+ conductor: new ForgeAgentConductor(
135
+ rootExecutionId, journal, this.options.clock, sha256Digest,
136
+ this.options.ownerAuthorizationVerifier, ledger,
137
+ ),
138
+ };
139
+ }
140
+
141
+ private async persist(rootExecutionId: string, beforeCount: number, additions: readonly ControlEventEnvelope[]): Promise<void> {
142
+ if (additions.length === 0) return;
143
+ const transaction = await this.options.adapter.begin();
144
+ try {
145
+ const cursor = await transaction.query(
146
+ `SELECT COALESCE(MAX(event_sequence), 0) AS current_sequence
147
+ FROM _forge_agent_fabric_control_events WHERE root_execution_id = $1`,
148
+ [rootExecutionId],
149
+ );
150
+ if (Number(cursor.rows[0]?.current_sequence) !== beforeCount) {
151
+ throw new AgentFabricError("AF_CONFLICT", "Local control journal compare-and-swap failed");
152
+ }
153
+ for (const event of additions) {
154
+ await transaction.query(
155
+ `INSERT INTO _forge_agent_fabric_control_events
156
+ (root_execution_id, event_sequence, event_id, idempotency_key, envelope_json)
157
+ VALUES ($1, $2, $3, $4, $5)`,
158
+ [rootExecutionId, event.sequence, event.eventId, event.idempotencyKey ?? null,
159
+ stableStringify(event)],
160
+ );
161
+ }
162
+ await transaction.commit();
163
+ } catch (error) {
164
+ await transaction.rollback().catch(() => undefined);
165
+ throw error;
166
+ }
167
+ }
168
+
169
+ async transition<T>(
170
+ rootExecutionId: string,
171
+ operation: (conductor: ForgeAgentConductor) => T,
172
+ ): Promise<LocalControlTransitionResult<T>> {
173
+ return this.serialized(async () => {
174
+ if (this.externalRoots.has(rootExecutionId)) {
175
+ throw new AgentFabricError("AF_CONFLICT", "External attempt is still in flight for this root");
176
+ }
177
+ const before = await this.load(rootExecutionId);
178
+ const { journal, conductor } = this.hydrate(rootExecutionId, before);
179
+ const result = operation(conductor);
180
+ if (result && typeof result === "object" && "then" in result) {
181
+ throw new AgentFabricError("AF_INVALID_STATE", "Control transitions must be synchronous");
182
+ }
183
+ const after = journal.readAll();
184
+ const additions = after.slice(before.length);
185
+ await this.persist(rootExecutionId, before.length, additions);
186
+ return { result, events: after };
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Keep one external attempt per root while releasing the database writer
192
+ * lock for the model call. The permit is committed in a prior transition.
193
+ * A crash leaves it without an outcome, which recovery classifies as uncertain.
194
+ */
195
+ async runExternal<T>(
196
+ rootExecutionId: string,
197
+ operation: (conductor: ForgeAgentConductor) => Promise<T>,
198
+ ): Promise<LocalControlTransitionResult<T>> {
199
+ const prepared = await this.serialized(async () => {
200
+ if (this.externalRoots.has(rootExecutionId)) {
201
+ throw new AgentFabricError("AF_CONFLICT", "External attempt is already in flight for this root");
202
+ }
203
+ const before = await this.load(rootExecutionId);
204
+ const { journal, conductor } = this.hydrate(rootExecutionId, before);
205
+ this.externalRoots.add(rootExecutionId);
206
+ return { before, journal, conductor };
207
+ });
208
+ // No database transaction or writer lock remains open around the model call.
209
+ // A status read can therefore see only a committed journal prefix.
210
+ const execution = (async (): Promise<LocalControlTransitionResult<T>> => {
211
+ let result: T | undefined;
212
+ let failure: unknown;
213
+ try {
214
+ result = await operation(prepared.conductor);
215
+ } catch (error) {
216
+ failure = error;
217
+ }
218
+ const after = await this.serialized(async () => {
219
+ const events = prepared.journal.readAll();
220
+ await this.persist(rootExecutionId, prepared.before.length, events.slice(prepared.before.length));
221
+ return events;
222
+ });
223
+ if (failure !== undefined) throw failure;
224
+ return { result: result as T, events: after };
225
+ })();
226
+ this.externalRuns.add(execution);
227
+ try { return await execution; } finally {
228
+ this.externalRuns.delete(execution);
229
+ this.externalRoots.delete(rootExecutionId);
230
+ }
231
+ }
232
+
233
+ async close(): Promise<void> {
234
+ await Promise.allSettled([...this.externalRuns]);
235
+ this.closed = true;
236
+ await this.tail;
237
+ await this.options.adapter.close();
238
+ }
239
+ }
@@ -0,0 +1,18 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { AgentFabricError } from "./errors.ts";
4
+
5
+ /** Check each local state component before opening files or creating worktrees. */
6
+ export function localFabricPath(repositoryRoot: string, ...parts: string[]): string {
7
+ let current = repositoryRoot;
8
+ for (const part of [".forge", "local", "agent-fabric", ...parts]) {
9
+ if (!/^[A-Za-z0-9._-]+$/u.test(part) || part === "." || part === "..") {
10
+ throw new AgentFabricError("AF_INVALID_STATE", "Invalid local Agent Fabric path component");
11
+ }
12
+ current = join(current, part);
13
+ if (lstatSync(current, { throwIfNoEntry: false })?.isSymbolicLink()) {
14
+ throw new AgentFabricError("AF_INVALID_STATE", "Local Agent Fabric path traverses a symbolic link");
15
+ }
16
+ }
17
+ return current;
18
+ }
@@ -0,0 +1,142 @@
1
+ import { digestCanonical, sha256Digest, stableStringify } from "./canonical.ts";
2
+ import { AgentFabricError } from "./errors.ts";
3
+ import type { Digest } from "./types.ts";
4
+
5
+ /** Untrusted request data. This does not grant authority or start a task. */
6
+ export interface LocalCodingTaskProposal {
7
+ schemaVersion: 1;
8
+ repositoryId: string;
9
+ baseCommit: string;
10
+ goal: string;
11
+ acceptanceCriteria: readonly string[];
12
+ nonObjectives: readonly string[];
13
+ sourcePaths: readonly string[];
14
+ writablePaths: readonly string[];
15
+ requestedModelTargetId: string;
16
+ limits: {
17
+ maximumAttempts: number;
18
+ maximumWallClockMs: number;
19
+ maximumOutputTokens: number;
20
+ maximumContextBytes: number;
21
+ maximumPatchBytes: number;
22
+ expiresAt: number;
23
+ };
24
+ }
25
+
26
+ export interface ValidatedLocalCodingTaskProposal {
27
+ proposal: Readonly<LocalCodingTaskProposal>;
28
+ proposalDigest: Digest;
29
+ }
30
+
31
+ const MAX_PROPOSAL_BYTES = 32 * 1024;
32
+ const MAX_CONTEXT_BYTES = 32 * 1024;
33
+ const MAX_PATCH_BYTES = 48 * 1024;
34
+
35
+ function invalid(field: string): never {
36
+ throw new AgentFabricError("AF_INVALID_STATE", `Invalid local coding task proposal field: ${field}`);
37
+ }
38
+
39
+ function record(value: unknown, field: string, keys: readonly string[]): Record<string, unknown> {
40
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid(field);
41
+ const item = value as Record<string, unknown>;
42
+ const actual = Object.keys(item);
43
+ if (actual.length !== keys.length ||
44
+ keys.some((key) => !Object.hasOwn(item, key)) ||
45
+ actual.some((key) => !keys.includes(key))) invalid(field);
46
+ return item;
47
+ }
48
+
49
+ function boundedText(value: unknown, field: string, maxBytes: number): string {
50
+ if (typeof value !== "string" || value.length === 0 || value.trim() !== value ||
51
+ Buffer.byteLength(value, "utf8") > maxBytes || /[\u0000-\u001f\u007f]/u.test(value)) invalid(field);
52
+ return value;
53
+ }
54
+
55
+ function identifier(value: unknown, field: string): string {
56
+ const text = boundedText(value, field, 128);
57
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(text)) invalid(field);
58
+ return text;
59
+ }
60
+
61
+ function textList(value: unknown, field: string, minimum: number, maximum: number): string[] {
62
+ if (!Array.isArray(value) || value.length < minimum || value.length > maximum) invalid(field);
63
+ return value.map((item, index) => boundedText(item, `${field}[${index}]`, 512));
64
+ }
65
+
66
+ function relativeFilePath(value: unknown, field: string): string {
67
+ const path = boundedText(value, field, 240);
68
+ if (path.normalize("NFC") !== path || path.startsWith("/") ||
69
+ /[\\:*?"<>|]/u.test(path)) invalid(field);
70
+ const segments = path.split("/");
71
+ if (segments.some((segment) => segment.length === 0 || segment === "." ||
72
+ segment === ".." || [".git", ".forge", ".ssh", "node_modules", "_generated"].includes(segment.toLowerCase()) ||
73
+ /^\.env(?:\.|$)/iu.test(segment) ||
74
+ [".npmrc", ".pypirc", "forge.lock", "id_rsa", "id_ed25519"].includes(segment.toLowerCase()) ||
75
+ /\.(?:pem|key|p12|pfx)$/iu.test(segment) ||
76
+ segment.endsWith(".") || segment.endsWith(" ") ||
77
+ Buffer.byteLength(segment, "utf8") > 100 ||
78
+ /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/iu.test(segment))) invalid(field);
79
+ return path;
80
+ }
81
+
82
+ function pathList(value: unknown, field: string, maximum: number): string[] {
83
+ if (!Array.isArray(value) || value.length === 0 || value.length > maximum) invalid(field);
84
+ const paths = value.map((item, index) => relativeFilePath(item, `${field}[${index}]`));
85
+ if (new Set(paths.map((path) => path.toLowerCase())).size !== paths.length) invalid(field);
86
+ return paths;
87
+ }
88
+
89
+ function positiveInteger(value: unknown, field: string, maximum: number): number {
90
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > maximum) invalid(field);
91
+ return value;
92
+ }
93
+
94
+ function freezeDeep<T>(value: T): T {
95
+ if (value && typeof value === "object") {
96
+ for (const child of Object.values(value)) freezeDeep(child);
97
+ Object.freeze(value);
98
+ }
99
+ return value;
100
+ }
101
+
102
+ /**
103
+ * Validate and digest only a proposal. Trusted repository resolution, owner admission,
104
+ * path readback, resource reservation, and execution happen at later boundaries.
105
+ */
106
+ export function validateLocalCodingTaskProposal(input: unknown): ValidatedLocalCodingTaskProposal {
107
+ const canonical = stableStringify(input);
108
+ if (Buffer.byteLength(canonical, "utf8") > MAX_PROPOSAL_BYTES) invalid("proposalBytes");
109
+ const raw = record(JSON.parse(canonical) as unknown, "proposal", [
110
+ "schemaVersion", "repositoryId", "baseCommit", "goal", "acceptanceCriteria",
111
+ "nonObjectives", "sourcePaths", "writablePaths", "requestedModelTargetId", "limits",
112
+ ]);
113
+ if (raw.schemaVersion !== 1) invalid("schemaVersion");
114
+ if (typeof raw.baseCommit !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(raw.baseCommit)) {
115
+ invalid("baseCommit");
116
+ }
117
+ const limits = record(raw.limits, "limits", [
118
+ "maximumAttempts", "maximumWallClockMs", "maximumOutputTokens",
119
+ "maximumContextBytes", "maximumPatchBytes", "expiresAt",
120
+ ]);
121
+ const proposal: LocalCodingTaskProposal = {
122
+ schemaVersion: 1,
123
+ repositoryId: identifier(raw.repositoryId, "repositoryId"),
124
+ baseCommit: raw.baseCommit,
125
+ goal: boundedText(raw.goal, "goal", 4_000),
126
+ acceptanceCriteria: textList(raw.acceptanceCriteria, "acceptanceCriteria", 1, 16),
127
+ nonObjectives: textList(raw.nonObjectives, "nonObjectives", 0, 16),
128
+ sourcePaths: pathList(raw.sourcePaths, "sourcePaths", 24),
129
+ writablePaths: pathList(raw.writablePaths, "writablePaths", 12),
130
+ requestedModelTargetId: identifier(raw.requestedModelTargetId, "requestedModelTargetId"),
131
+ limits: {
132
+ maximumAttempts: positiveInteger(limits.maximumAttempts, "limits.maximumAttempts", 3),
133
+ maximumWallClockMs: positiveInteger(limits.maximumWallClockMs, "limits.maximumWallClockMs", 120_000),
134
+ maximumOutputTokens: positiveInteger(limits.maximumOutputTokens, "limits.maximumOutputTokens", 4_096),
135
+ maximumContextBytes: positiveInteger(limits.maximumContextBytes, "limits.maximumContextBytes", MAX_CONTEXT_BYTES),
136
+ maximumPatchBytes: positiveInteger(limits.maximumPatchBytes, "limits.maximumPatchBytes", MAX_PATCH_BYTES),
137
+ expiresAt: positiveInteger(limits.expiresAt, "limits.expiresAt", Number.MAX_SAFE_INTEGER),
138
+ },
139
+ };
140
+ const proposalDigest = digestCanonical(proposal, sha256Digest);
141
+ return { proposal: freezeDeep(proposal), proposalDigest };
142
+ }