@agent-relay/factory 0.1.57 → 0.1.60
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/README.md +152 -1
- package/dist/cli/fleet.d.ts +20 -1
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +550 -106
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +1778 -515
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +104 -6
- package/dist/config/schema.js.map +1 -1
- package/dist/delivery/ticket-dispatch.d.ts +15 -0
- package/dist/delivery/ticket-dispatch.d.ts.map +1 -0
- package/dist/delivery/ticket-dispatch.js +20 -0
- package/dist/delivery/ticket-dispatch.js.map +1 -0
- package/dist/feature-guardian/conversation.d.ts +251 -0
- package/dist/feature-guardian/conversation.d.ts.map +1 -0
- package/dist/feature-guardian/conversation.js +1893 -0
- package/dist/feature-guardian/conversation.js.map +1 -0
- package/dist/feature-guardian/index.d.ts +2 -0
- package/dist/feature-guardian/index.d.ts.map +1 -0
- package/dist/feature-guardian/index.js +2 -0
- package/dist/feature-guardian/index.js.map +1 -0
- package/dist/git/agent-worktree.d.ts.map +1 -1
- package/dist/git/agent-worktree.js +4 -0
- package/dist/git/agent-worktree.js.map +1 -1
- package/dist/github/index.d.ts +2 -0
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +1 -0
- package/dist/github/index.js.map +1 -1
- package/dist/github/routed-pr-babysitter.d.ts +48 -0
- package/dist/github/routed-pr-babysitter.d.ts.map +1 -0
- package/dist/github/routed-pr-babysitter.js +197 -0
- package/dist/github/routed-pr-babysitter.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/intake/index.d.ts +4 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +4 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/notion-relay-claim.d.ts +32 -0
- package/dist/intake/notion-relay-claim.d.ts.map +1 -0
- package/dist/intake/notion-relay-claim.js +173 -0
- package/dist/intake/notion-relay-claim.js.map +1 -0
- package/dist/intake/notion-relay-contract.d.ts +33 -0
- package/dist/intake/notion-relay-contract.d.ts.map +1 -0
- package/dist/intake/notion-relay-contract.js +163 -0
- package/dist/intake/notion-relay-contract.js.map +1 -0
- package/dist/intake/notion.d.ts +375 -0
- package/dist/intake/notion.d.ts.map +1 -0
- package/dist/intake/notion.js +773 -0
- package/dist/intake/notion.js.map +1 -0
- package/dist/issue-key-match.d.ts +6 -0
- package/dist/issue-key-match.d.ts.map +1 -1
- package/dist/issue-key-match.js +14 -0
- package/dist/issue-key-match.js.map +1 -1
- package/dist/mount/github-api-issue-read.d.ts +28 -0
- package/dist/mount/github-api-issue-read.d.ts.map +1 -0
- package/dist/mount/github-api-issue-read.js +163 -0
- package/dist/mount/github-api-issue-read.js.map +1 -0
- package/dist/mount/local-mount-preflight.d.ts +2 -0
- package/dist/mount/local-mount-preflight.d.ts.map +1 -1
- package/dist/mount/local-mount-preflight.js +59 -9
- package/dist/mount/local-mount-preflight.js.map +1 -1
- package/dist/mount/mount-auth-error.d.ts +41 -0
- package/dist/mount/mount-auth-error.d.ts.map +1 -0
- package/dist/mount/mount-auth-error.js +94 -0
- package/dist/mount/mount-auth-error.js.map +1 -0
- package/dist/mount/relayfile-binary.d.ts +5 -0
- package/dist/mount/relayfile-binary.d.ts.map +1 -1
- package/dist/mount/relayfile-binary.js +16 -2
- package/dist/mount/relayfile-binary.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +18 -3
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +157 -13
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
- package/dist/mount/relayfile-github-connection-write.js +6 -3
- package/dist/mount/relayfile-github-connection-write.js.map +1 -1
- package/dist/mount/workspace-mirror.d.ts +13 -0
- package/dist/mount/workspace-mirror.d.ts.map +1 -0
- package/dist/mount/workspace-mirror.js +94 -0
- package/dist/mount/workspace-mirror.js.map +1 -0
- package/dist/observability/events.d.ts +32 -32
- package/dist/orchestrator/batch-tracker.d.ts +2 -1
- package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
- package/dist/orchestrator/batch-tracker.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +51 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +1736 -116
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/fleet.d.ts +4 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/index.d.ts +1 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/mount.d.ts +60 -1
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +66 -1
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/state/file-state-store.d.ts +15 -1
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +210 -2
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts +15 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +105 -0
- package/dist/state/in-memory-state-store.js.map +1 -1
- package/dist/testing/fakes.d.ts +4 -0
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +6 -0
- package/dist/testing/fakes.js.map +1 -1
- package/dist/triage/schema.d.ts +169 -0
- package/dist/triage/schema.d.ts.map +1 -1
- package/dist/triage/schema.js +21 -0
- package/dist/triage/schema.js.map +1 -1
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version-info.d.ts +21 -0
- package/dist/version-info.d.ts.map +1 -0
- package/dist/version-info.js +146 -0
- package/dist/version-info.js.map +1 -0
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +51 -0
- package/dist/writeback/github.js.map +1 -1
- package/package.json +10 -1
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import lockfile from 'proper-lockfile';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
const INTAKE_LOCK_STALE_MS = 60_000;
|
|
8
|
+
const recipeSchema = z.enum(['single', 'workflow', 'team']);
|
|
9
|
+
const repoTargetSchema = z.object({
|
|
10
|
+
repo: z.string().regex(/^[^/\s]+\/[^/\s]+$/u, 'repo must be owner/name'),
|
|
11
|
+
labels: z.array(z.string().trim().min(1)).default([]),
|
|
12
|
+
publicSummary: z.string().trim().min(1).optional(),
|
|
13
|
+
}).strict();
|
|
14
|
+
const workspaceTargetSchema = z.object({
|
|
15
|
+
projectPath: z.string().trim().min(1).refine(isAbsolute, 'projectPath must be absolute'),
|
|
16
|
+
node: z.string().trim().min(1).optional(),
|
|
17
|
+
}).strict();
|
|
18
|
+
const targetSchema = z.union([repoTargetSchema, workspaceTargetSchema]);
|
|
19
|
+
const workerMountTransportSchema = z.discriminatedUnion('kind', [
|
|
20
|
+
z.object({ kind: z.literal('local') }).strict(),
|
|
21
|
+
z.object({ kind: z.literal('relay-channel') }).strict(),
|
|
22
|
+
]).default({ kind: 'local' });
|
|
23
|
+
const contractMarkerTokenSchema = z.string().trim().min(1).regex(/^[A-Za-z0-9._-]+$/u, 'portable delivery identifiers must use the marker-safe ASCII alphabet');
|
|
24
|
+
const contractDeliverySchema = z.object({
|
|
25
|
+
kind: z.literal('relay-channel'),
|
|
26
|
+
channel: contractMarkerTokenSchema,
|
|
27
|
+
messageIds: z.array(contractMarkerTokenSchema).min(1, 'portable delivery must include at least one message id'),
|
|
28
|
+
encoding: z.literal('base64-chunks-v1'),
|
|
29
|
+
}).strict();
|
|
30
|
+
const bootstrapSchema = z.object({
|
|
31
|
+
authorizedPageId: z.string().trim().min(1),
|
|
32
|
+
reason: z.string().trim().min(1),
|
|
33
|
+
status: z.literal('ready'),
|
|
34
|
+
title: z.string().trim().min(1),
|
|
35
|
+
recipe: recipeSchema,
|
|
36
|
+
summary: z.string().trim().min(1),
|
|
37
|
+
targets: z.array(targetSchema).min(1),
|
|
38
|
+
}).strict();
|
|
39
|
+
const manifestSchema = z.object({
|
|
40
|
+
version: z.literal(1),
|
|
41
|
+
mountRoot: z.string().trim().min(1).default('.integrations/notion'),
|
|
42
|
+
workerMountRoot: z.string().trim().min(1).default('.integrations/notion'),
|
|
43
|
+
workerMountTransport: workerMountTransportSchema,
|
|
44
|
+
statePath: z.string().trim().min(1).default('.factory/notion-intake-state.json'),
|
|
45
|
+
tasks: z.array(z.object({
|
|
46
|
+
page: z.string().trim().min(1),
|
|
47
|
+
bootstrap: bootstrapSchema.optional(),
|
|
48
|
+
}).strict()).min(1),
|
|
49
|
+
}).strict();
|
|
50
|
+
/** Load and validate an operator-reviewed intake manifest with paths resolved from the manifest directory. */
|
|
51
|
+
export async function loadNotionIntakeManifest(path) {
|
|
52
|
+
const manifestPath = resolve(path);
|
|
53
|
+
const parsed = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')));
|
|
54
|
+
const base = dirname(manifestPath);
|
|
55
|
+
return {
|
|
56
|
+
...parsed,
|
|
57
|
+
mountRoot: resolve(base, parsed.mountRoot),
|
|
58
|
+
statePath: resolve(base, parsed.statePath),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Plan or execute every mounted Notion destination and return one durable result per source key. */
|
|
62
|
+
export async function runNotionIntake(input) {
|
|
63
|
+
if (!input.dispatch)
|
|
64
|
+
return await runNotionIntakeUnlocked(input);
|
|
65
|
+
await mkdir(dirname(input.manifest.statePath), { recursive: true, mode: 0o700 });
|
|
66
|
+
const release = await lockfile.lock(input.manifest.statePath, {
|
|
67
|
+
realpath: false,
|
|
68
|
+
stale: INTAKE_LOCK_STALE_MS,
|
|
69
|
+
update: INTAKE_LOCK_STALE_MS / 2,
|
|
70
|
+
retries: { retries: 50, factor: 1.2, minTimeout: 10, maxTimeout: 100, randomize: true },
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
return await runNotionIntakeUnlocked(input);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await release();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function runNotionIntakeUnlocked(input) {
|
|
80
|
+
const tasks = await normalizeNotionManifest(input.manifest);
|
|
81
|
+
const state = await readIntakeState(input.manifest.statePath);
|
|
82
|
+
const results = [];
|
|
83
|
+
for (const task of tasks) {
|
|
84
|
+
try {
|
|
85
|
+
await assertMountedTaskUnchanged(task);
|
|
86
|
+
const receiptBefore = state.receipts[task.sourceKey]
|
|
87
|
+
? JSON.stringify(state.receipts[task.sourceKey])
|
|
88
|
+
: undefined;
|
|
89
|
+
let result;
|
|
90
|
+
if ('repo' in task.target) {
|
|
91
|
+
result = await publishRepoTask(task, input, state);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
result = await dispatchWorkspaceTask(task, input, state);
|
|
95
|
+
}
|
|
96
|
+
const receiptAfter = state.receipts[task.sourceKey]
|
|
97
|
+
? JSON.stringify(state.receipts[task.sourceKey])
|
|
98
|
+
: undefined;
|
|
99
|
+
if (input.dispatch && receiptAfter && receiptAfter !== receiptBefore) {
|
|
100
|
+
await writeIntakeState(input.manifest.statePath, state);
|
|
101
|
+
}
|
|
102
|
+
results.push(result);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
results.push({
|
|
106
|
+
sourceKey: task.sourceKey,
|
|
107
|
+
pageId: task.pageId,
|
|
108
|
+
target: task.target,
|
|
109
|
+
status: 'blocked',
|
|
110
|
+
digest: task.digest,
|
|
111
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: results.every((result) => result.status !== 'blocked'),
|
|
117
|
+
dispatch: input.dispatch,
|
|
118
|
+
results,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Read mounted page bodies and normalize strict headers or exact bootstrap mappings into Factory tasks. */
|
|
122
|
+
export async function normalizeNotionManifest(manifest) {
|
|
123
|
+
const normalized = [];
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (const task of manifest.tasks) {
|
|
126
|
+
const pageId = normalizeNotionPageId(task.page);
|
|
127
|
+
const sourcePath = join(manifest.mountRoot, 'pages', pageId, 'content.md');
|
|
128
|
+
const content = await readFile(sourcePath, 'utf8');
|
|
129
|
+
const spec = task.bootstrap
|
|
130
|
+
? normalizedBootstrapSpec(task.bootstrap, pageId)
|
|
131
|
+
: parseChiefSpecHeader(content);
|
|
132
|
+
const authorizationDigestInput = task.bootstrap ? canonicalJson(spec) : undefined;
|
|
133
|
+
const contentDigest = createHash('sha256').update(content).digest('hex');
|
|
134
|
+
const digest = contractDigest(content, authorizationDigestInput);
|
|
135
|
+
for (const target of spec.targets) {
|
|
136
|
+
const targetKey = 'repo' in target ? `repo:${target.repo.toLowerCase()}` : `workspace:${resolve(target.projectPath)}`;
|
|
137
|
+
const sourceKey = `notion:${pageId}:${targetKey}`;
|
|
138
|
+
if (seen.has(sourceKey))
|
|
139
|
+
throw new Error(`duplicate Notion intake destination: ${sourceKey}`);
|
|
140
|
+
seen.add(sourceKey);
|
|
141
|
+
normalized.push({
|
|
142
|
+
pageId,
|
|
143
|
+
sourceKey,
|
|
144
|
+
sourcePath,
|
|
145
|
+
workerSourcePath: 'repo' in target || manifest.workerMountTransport.kind === 'relay-channel'
|
|
146
|
+
? join(manifest.workerMountRoot, 'pages', pageId, 'content.md')
|
|
147
|
+
: sourcePath,
|
|
148
|
+
content,
|
|
149
|
+
...(authorizationDigestInput ? { authorizationDigestInput } : {}),
|
|
150
|
+
contentDigest,
|
|
151
|
+
digest,
|
|
152
|
+
title: spec.title,
|
|
153
|
+
summary: spec.summary,
|
|
154
|
+
recipe: spec.recipe,
|
|
155
|
+
target: 'projectPath' in target
|
|
156
|
+
? { ...target, projectPath: resolve(target.projectPath) }
|
|
157
|
+
: target,
|
|
158
|
+
bootstrap: Boolean(task.bootstrap),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return normalized;
|
|
163
|
+
}
|
|
164
|
+
/** Extract one boundary-delimited Notion UUID from a bare identifier or application URL. */
|
|
165
|
+
export function normalizeNotionPageId(value) {
|
|
166
|
+
const lowered = value.toLowerCase();
|
|
167
|
+
const hyphenated = /(?<![0-9a-f])([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})(?![0-9a-f])/u.exec(lowered);
|
|
168
|
+
const compact = hyphenated?.slice(1).join('') ?? lowered.match(/(?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f])/u)?.[0];
|
|
169
|
+
if (!compact)
|
|
170
|
+
throw new Error(`Notion page reference does not contain a 32-character page id: ${value}`);
|
|
171
|
+
return `${compact.slice(0, 8)}-${compact.slice(8, 12)}-${compact.slice(12, 16)}-${compact.slice(16, 20)}-${compact.slice(20)}`;
|
|
172
|
+
}
|
|
173
|
+
/** Parse the fail-closed Chief Spec header at the start of a mounted Notion page. */
|
|
174
|
+
export function parseChiefSpecHeader(content) {
|
|
175
|
+
const lines = content.replaceAll('\r\n', '\n').split('\n');
|
|
176
|
+
if (lines[0]?.trim() !== '# Chief Spec') {
|
|
177
|
+
throw new Error('Notion intake refused: first line must be exactly "# Chief Spec"');
|
|
178
|
+
}
|
|
179
|
+
const fields = new Map();
|
|
180
|
+
const allowedFields = new Set(['status', 'title', 'summary', 'recipe', 'repos', 'project-paths', 'node', 'public-summary']);
|
|
181
|
+
for (const line of lines.slice(1)) {
|
|
182
|
+
if (line.trim() === '')
|
|
183
|
+
break;
|
|
184
|
+
const match = /^([A-Za-z][A-Za-z-]*):\s*(.+)$/u.exec(line.trim());
|
|
185
|
+
if (!match)
|
|
186
|
+
throw new Error(`Notion intake refused malformed Chief Spec header line: ${line}`);
|
|
187
|
+
const key = match[1].toLowerCase();
|
|
188
|
+
if (!allowedFields.has(key))
|
|
189
|
+
throw new Error(`Notion intake refused unknown Chief Spec field: ${match[1]}`);
|
|
190
|
+
if (fields.has(key))
|
|
191
|
+
throw new Error(`Notion intake refused duplicate Chief Spec field: ${match[1]}`);
|
|
192
|
+
fields.set(key, match[2].trim());
|
|
193
|
+
}
|
|
194
|
+
if (fields.get('status')?.toLowerCase() !== 'ready') {
|
|
195
|
+
throw new Error('Notion intake refused: Chief Spec Status must be ready');
|
|
196
|
+
}
|
|
197
|
+
const title = requiredField(fields, 'title');
|
|
198
|
+
const summary = requiredField(fields, 'summary');
|
|
199
|
+
const recipe = recipeSchema.parse(requiredField(fields, 'recipe').toLowerCase());
|
|
200
|
+
const repos = splitField(fields.get('repos')).map((repo) => repoTargetSchema.parse({
|
|
201
|
+
repo,
|
|
202
|
+
...(fields.get('public-summary') ? { publicSummary: fields.get('public-summary') } : {}),
|
|
203
|
+
}));
|
|
204
|
+
const projectPaths = splitField(fields.get('project-paths')).map((projectPath) => workspaceTargetSchema.parse({
|
|
205
|
+
projectPath,
|
|
206
|
+
...(fields.get('node') ? { node: fields.get('node') } : {}),
|
|
207
|
+
}));
|
|
208
|
+
const targets = [...repos, ...projectPaths];
|
|
209
|
+
if (targets.length === 0)
|
|
210
|
+
throw new Error('Notion intake refused: Chief Spec requires Repos or Project-Paths');
|
|
211
|
+
return { title, summary, recipe, targets };
|
|
212
|
+
}
|
|
213
|
+
/** GitHub CLI publisher with shell-free arguments, bounded output, and source-marker reconciliation. */
|
|
214
|
+
export class GhCliIssuePublisher {
|
|
215
|
+
async repositoryVisibility(repo) {
|
|
216
|
+
const output = (await runGh(['repo', 'view', repo, '--json', 'visibility', '--jq', '.visibility'])).trim().toLowerCase();
|
|
217
|
+
if (output !== 'public' && output !== 'private' && output !== 'internal') {
|
|
218
|
+
throw new Error(`GitHub returned unknown visibility for ${repo}: ${output || '(empty)'}`);
|
|
219
|
+
}
|
|
220
|
+
return output;
|
|
221
|
+
}
|
|
222
|
+
async missingLabels(repo, labels) {
|
|
223
|
+
const output = await runGh(['api', '--paginate', `repos/${repo}/labels?per_page=100`, '--jq', '.[].name']);
|
|
224
|
+
const available = new Set(output.split('\n').map((label) => label.trim()).filter(Boolean));
|
|
225
|
+
return labels.filter((label) => !available.has(label));
|
|
226
|
+
}
|
|
227
|
+
async findBySource(repo, sourceKey) {
|
|
228
|
+
const output = await runGh([
|
|
229
|
+
'issue', 'list', '--repo', repo, '--state', 'all', '--limit', '100',
|
|
230
|
+
'--search', `"factory-source:${sourceKey}" in:body`,
|
|
231
|
+
'--json', 'number,url,body',
|
|
232
|
+
]);
|
|
233
|
+
const issues = z.array(z.object({ number: z.number().int().positive(), url: z.string().url(), body: z.string() })).parse(JSON.parse(output));
|
|
234
|
+
const marker = sourceMarker(sourceKey);
|
|
235
|
+
return issues.find((issue) => issue.body.includes(marker));
|
|
236
|
+
}
|
|
237
|
+
async createIssue(input) {
|
|
238
|
+
const args = ['issue', 'create', '--repo', input.repo, '--title', input.title, '--body-file', '-'];
|
|
239
|
+
for (const label of input.labels)
|
|
240
|
+
args.push('--label', label);
|
|
241
|
+
const url = (await runGh(args, input.body)).trim();
|
|
242
|
+
const number = Number(/\/issues\/(\d+)\/?$/u.exec(url)?.[1]);
|
|
243
|
+
if (!Number.isInteger(number) || number <= 0)
|
|
244
|
+
throw new Error(`GitHub issue create returned an unexpected URL: ${url}`);
|
|
245
|
+
return { number, url };
|
|
246
|
+
}
|
|
247
|
+
async updateIssue(input) {
|
|
248
|
+
await runGh(['issue', 'edit', String(input.number), '--repo', input.repo, '--body-file', '-'], input.body);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function publishRepoTask(task, input, state) {
|
|
252
|
+
const target = repoTargetSchema.parse(task.target);
|
|
253
|
+
const base = {
|
|
254
|
+
sourceKey: task.sourceKey,
|
|
255
|
+
pageId: task.pageId,
|
|
256
|
+
target,
|
|
257
|
+
status: 'ready',
|
|
258
|
+
digest: task.digest,
|
|
259
|
+
};
|
|
260
|
+
if (!input.dispatch)
|
|
261
|
+
return base;
|
|
262
|
+
if (!input.github)
|
|
263
|
+
return { ...base, status: 'blocked', reason: 'GitHub issue publisher is not configured' };
|
|
264
|
+
const receipt = state.receipts[task.sourceKey];
|
|
265
|
+
if (receipt && receipt.kind !== 'github') {
|
|
266
|
+
return { ...base, status: 'blocked', reason: 'intake receipt kind does not match repository destination' };
|
|
267
|
+
}
|
|
268
|
+
if (receipt?.digest !== undefined && receipt.digest !== task.digest) {
|
|
269
|
+
return { ...base, status: 'blocked', issue: receipt.issue, reason: 'mounted spec changed after the lifecycle issue was created' };
|
|
270
|
+
}
|
|
271
|
+
const existing = await input.github.findBySource(target.repo, task.sourceKey);
|
|
272
|
+
if (existing) {
|
|
273
|
+
if (receipt && (existing.number !== receipt.issue.number || existing.url !== receipt.issue.url)) {
|
|
274
|
+
return { ...base, status: 'blocked', issue: existing, reason: 'lifecycle issue does not match the local receipt cache' };
|
|
275
|
+
}
|
|
276
|
+
const currentDigest = digestFromBody(existing.body);
|
|
277
|
+
if (!currentDigest) {
|
|
278
|
+
return { ...base, status: 'blocked', issue: existing, reason: 'existing lifecycle issue is missing its source digest' };
|
|
279
|
+
}
|
|
280
|
+
if (currentDigest !== task.digest) {
|
|
281
|
+
return { ...base, status: 'blocked', issue: existing, reason: 'mounted spec changed after the lifecycle issue was created' };
|
|
282
|
+
}
|
|
283
|
+
let claim = await observeNotionClaim(task, input);
|
|
284
|
+
if (!claim) {
|
|
285
|
+
if (!receipt) {
|
|
286
|
+
return {
|
|
287
|
+
...base,
|
|
288
|
+
status: 'blocked',
|
|
289
|
+
issue: existing,
|
|
290
|
+
reason: 'lifecycle issue marker has neither a durable shared claim nor a local migration receipt',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
claim = (await claimNotionTask(task, input)).claim;
|
|
294
|
+
}
|
|
295
|
+
const bodyDelivery = contractDeliveryFromBody(existing.body);
|
|
296
|
+
if (input.manifest.workerMountTransport.kind === 'local') {
|
|
297
|
+
if (receipt?.delivery || bodyDelivery) {
|
|
298
|
+
return { ...base, status: 'blocked', issue: existing, reason: 'portable Notion delivery cannot be downgraded to a local worker mount' };
|
|
299
|
+
}
|
|
300
|
+
state.receipts[task.sourceKey] = receipt ?? {
|
|
301
|
+
kind: 'github',
|
|
302
|
+
digest: task.digest,
|
|
303
|
+
issue: { number: existing.number, url: existing.url },
|
|
304
|
+
dispatchedAt: claim.claimedAt,
|
|
305
|
+
};
|
|
306
|
+
return { ...base, status: 'already-dispatched', issue: existing };
|
|
307
|
+
}
|
|
308
|
+
const visibility = await input.github.repositoryVisibility(target.repo);
|
|
309
|
+
if (visibility === 'public' && !target.publicSummary) {
|
|
310
|
+
return { ...base, status: 'blocked', issue: existing, reason: 'public repository requires an explicit publicSummary; mounted content was not copied' };
|
|
311
|
+
}
|
|
312
|
+
const summary = visibility === 'public' ? target.publicSummary : task.summary;
|
|
313
|
+
const bodyWasEdited = existing.body !== renderIssueBody(task, summary, bodyDelivery);
|
|
314
|
+
if (bodyWasEdited) {
|
|
315
|
+
return {
|
|
316
|
+
...base,
|
|
317
|
+
status: 'blocked',
|
|
318
|
+
issue: existing,
|
|
319
|
+
reason: 'existing lifecycle issue body was edited; refusing to overwrite it during portable mount migration',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (receipt?.delivery && bodyDelivery && !sameContractDelivery(receipt.delivery, bodyDelivery)) {
|
|
323
|
+
return { ...base, status: 'blocked', issue: existing, reason: 'lifecycle issue portable delivery does not match its local receipt cache' };
|
|
324
|
+
}
|
|
325
|
+
const delivery = await prepareContractDelivery(task, input, receipt?.delivery ?? bodyDelivery);
|
|
326
|
+
if (delivery && !bodyDelivery) {
|
|
327
|
+
await assertMountedTaskUnchanged(task);
|
|
328
|
+
await input.github.updateIssue({
|
|
329
|
+
repo: target.repo,
|
|
330
|
+
number: existing.number,
|
|
331
|
+
body: renderIssueBody(task, summary, delivery),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
state.receipts[task.sourceKey] = {
|
|
335
|
+
kind: 'github',
|
|
336
|
+
digest: task.digest,
|
|
337
|
+
issue: { number: existing.number, url: existing.url },
|
|
338
|
+
...(delivery ? { delivery } : {}),
|
|
339
|
+
dispatchedAt: receipt?.dispatchedAt ?? claim.claimedAt,
|
|
340
|
+
};
|
|
341
|
+
return { ...base, status: 'already-dispatched', issue: existing };
|
|
342
|
+
}
|
|
343
|
+
if (receipt) {
|
|
344
|
+
return { ...base, status: 'blocked', issue: receipt.issue, reason: 'local lifecycle receipt exists but its issue marker was not found' };
|
|
345
|
+
}
|
|
346
|
+
const visibility = await input.github.repositoryVisibility(target.repo);
|
|
347
|
+
if (visibility === 'public' && !target.publicSummary) {
|
|
348
|
+
return { ...base, status: 'blocked', reason: 'public repository requires an explicit publicSummary; mounted content was not copied' };
|
|
349
|
+
}
|
|
350
|
+
const labels = [...new Set(['factory-ready', `agent:${task.recipe}`, ...target.labels])];
|
|
351
|
+
const missing = await input.github.missingLabels(target.repo, labels);
|
|
352
|
+
if (missing.length > 0) {
|
|
353
|
+
return { ...base, status: 'blocked', reason: `missing required GitHub labels: ${missing.join(', ')}` };
|
|
354
|
+
}
|
|
355
|
+
const delivery = await prepareContractDelivery(task, input);
|
|
356
|
+
const claim = await claimNotionTask(task, input);
|
|
357
|
+
if (claim.status === 'existing') {
|
|
358
|
+
return {
|
|
359
|
+
...base,
|
|
360
|
+
status: 'blocked',
|
|
361
|
+
reason: 'durable Notion claim already exists; refusing lifecycle issue creation from this dispatcher',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
await assertMountedTaskUnchanged(task);
|
|
365
|
+
const issue = await input.github.createIssue({
|
|
366
|
+
repo: target.repo,
|
|
367
|
+
title: factoryIssueTitle(task.title),
|
|
368
|
+
labels,
|
|
369
|
+
body: renderIssueBody(task, visibility === 'public' ? target.publicSummary : task.summary, delivery),
|
|
370
|
+
});
|
|
371
|
+
state.receipts[task.sourceKey] = {
|
|
372
|
+
kind: 'github',
|
|
373
|
+
digest: task.digest,
|
|
374
|
+
issue,
|
|
375
|
+
...(delivery ? { delivery } : {}),
|
|
376
|
+
dispatchedAt: claim.claim.claimedAt,
|
|
377
|
+
};
|
|
378
|
+
return { ...base, status: 'dispatched', issue };
|
|
379
|
+
}
|
|
380
|
+
function factoryIssueTitle(title) {
|
|
381
|
+
return title.toLowerCase().startsWith('[factory]') ? title : `[factory] ${title}`;
|
|
382
|
+
}
|
|
383
|
+
async function dispatchWorkspaceTask(task, input, state) {
|
|
384
|
+
const target = workspaceTargetSchema.parse(task.target);
|
|
385
|
+
const base = {
|
|
386
|
+
sourceKey: task.sourceKey,
|
|
387
|
+
pageId: task.pageId,
|
|
388
|
+
target,
|
|
389
|
+
status: 'ready',
|
|
390
|
+
digest: task.digest,
|
|
391
|
+
};
|
|
392
|
+
if (!input.dispatch)
|
|
393
|
+
return base;
|
|
394
|
+
const receipt = state.receipts[task.sourceKey];
|
|
395
|
+
if (receipt) {
|
|
396
|
+
if (receipt.kind !== 'workspace') {
|
|
397
|
+
return { ...base, status: 'blocked', reason: 'intake receipt kind does not match workspace destination' };
|
|
398
|
+
}
|
|
399
|
+
if (receipt.digest !== task.digest) {
|
|
400
|
+
return { ...base, status: 'blocked', agent: receipt.agent, node: receipt.node, reason: 'mounted spec changed after workspace dispatch' };
|
|
401
|
+
}
|
|
402
|
+
await claimNotionTask(task, input);
|
|
403
|
+
const needsPortableMigration = input.manifest.workerMountTransport.kind !== 'local' && !receipt.delivery;
|
|
404
|
+
if (needsPortableMigration) {
|
|
405
|
+
if (!input.workspace?.redispatch) {
|
|
406
|
+
return {
|
|
407
|
+
...base,
|
|
408
|
+
status: 'blocked',
|
|
409
|
+
agent: receipt.agent,
|
|
410
|
+
node: receipt.node,
|
|
411
|
+
reason: 'portable workspace mount migration requires a workspace redispatcher',
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const migrationClaim = await claimNotionTask(task, input, `${task.sourceKey}:portable-mount`);
|
|
415
|
+
if (migrationClaim.status === 'existing') {
|
|
416
|
+
return {
|
|
417
|
+
...base,
|
|
418
|
+
status: 'blocked',
|
|
419
|
+
agent: receipt.agent,
|
|
420
|
+
node: receipt.node,
|
|
421
|
+
reason: 'durable portable-mount migration claim already exists; refusing a second workspace redispatch',
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const delivery = await prepareContractDelivery(task, input, receipt.delivery);
|
|
426
|
+
if (delivery && !sameContractDelivery(receipt.delivery, delivery)) {
|
|
427
|
+
if (!input.workspace?.redispatch) {
|
|
428
|
+
return {
|
|
429
|
+
...base,
|
|
430
|
+
status: 'blocked',
|
|
431
|
+
agent: receipt.agent,
|
|
432
|
+
node: receipt.node,
|
|
433
|
+
reason: 'portable workspace mount migration requires a workspace redispatcher',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
const refreshed = await input.workspace.redispatch({
|
|
437
|
+
name: receipt.agent,
|
|
438
|
+
invocationId: `factory:${task.sourceKey}:${task.digest}:portable-mount`,
|
|
439
|
+
node: receipt.node ?? target.node,
|
|
440
|
+
projectPath: target.projectPath,
|
|
441
|
+
title: task.title,
|
|
442
|
+
task: renderWorkspaceTask(task, delivery),
|
|
443
|
+
});
|
|
444
|
+
state.receipts[task.sourceKey] = {
|
|
445
|
+
...receipt,
|
|
446
|
+
agent: refreshed.agent,
|
|
447
|
+
...(refreshed.node ? { node: refreshed.node } : {}),
|
|
448
|
+
delivery,
|
|
449
|
+
dispatchedAt: (input.now?.() ?? new Date()).toISOString(),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const currentReceipt = state.receipts[task.sourceKey];
|
|
453
|
+
return { ...base, status: 'already-dispatched', agent: currentReceipt.agent, node: currentReceipt.node };
|
|
454
|
+
}
|
|
455
|
+
if (!input.workspace)
|
|
456
|
+
return { ...base, status: 'blocked', reason: 'workspace task dispatcher is not configured' };
|
|
457
|
+
const suffix = createHash('sha256').update(task.sourceKey).digest('hex').slice(0, 8);
|
|
458
|
+
const name = `notion-${task.pageId.slice(-8)}-${suffix}`;
|
|
459
|
+
const claim = await claimNotionTask(task, input);
|
|
460
|
+
if (claim.status === 'existing') {
|
|
461
|
+
if (!input.workspace.find) {
|
|
462
|
+
return {
|
|
463
|
+
...base,
|
|
464
|
+
status: 'blocked',
|
|
465
|
+
reason: 'durable Notion claim already exists but this workspace dispatcher cannot look up running agents; refusing a second spawn',
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const running = await input.workspace.find(name);
|
|
469
|
+
if (!running) {
|
|
470
|
+
return {
|
|
471
|
+
...base,
|
|
472
|
+
status: 'blocked',
|
|
473
|
+
reason: 'durable Notion claim already exists but no running workspace agent was found; refusing a second spawn',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const migrationSourceKey = `${task.sourceKey}:portable-mount`;
|
|
477
|
+
if (input.manifest.workerMountTransport.kind !== 'local' &&
|
|
478
|
+
await observeNotionClaim(task, input, migrationSourceKey)) {
|
|
479
|
+
return {
|
|
480
|
+
...base,
|
|
481
|
+
status: 'blocked',
|
|
482
|
+
agent: running.agent,
|
|
483
|
+
node: running.node,
|
|
484
|
+
reason: 'durable portable-mount migration claim exists without a local completion receipt; refusing to assume the running worker was refreshed',
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const delivery = await prepareContractDelivery(task, input);
|
|
488
|
+
state.receipts[task.sourceKey] = {
|
|
489
|
+
kind: 'workspace',
|
|
490
|
+
digest: task.digest,
|
|
491
|
+
agent: running.agent,
|
|
492
|
+
...(running.node ? { node: running.node } : {}),
|
|
493
|
+
...(delivery ? { delivery } : {}),
|
|
494
|
+
dispatchedAt: claim.claim.claimedAt,
|
|
495
|
+
};
|
|
496
|
+
return { ...base, status: 'already-dispatched', agent: running.agent, node: running.node };
|
|
497
|
+
}
|
|
498
|
+
const delivery = await prepareContractDelivery(task, input);
|
|
499
|
+
await assertMountedTaskUnchanged(task);
|
|
500
|
+
const result = await input.workspace.dispatch({
|
|
501
|
+
name,
|
|
502
|
+
invocationId: `factory:${task.sourceKey}:${task.digest}`,
|
|
503
|
+
node: target.node,
|
|
504
|
+
projectPath: target.projectPath,
|
|
505
|
+
title: task.title,
|
|
506
|
+
task: renderWorkspaceTask(task, delivery),
|
|
507
|
+
});
|
|
508
|
+
state.receipts[task.sourceKey] = {
|
|
509
|
+
kind: 'workspace',
|
|
510
|
+
digest: task.digest,
|
|
511
|
+
agent: result.agent,
|
|
512
|
+
...(result.node ? { node: result.node } : {}),
|
|
513
|
+
...(delivery ? { delivery } : {}),
|
|
514
|
+
dispatchedAt: claim.claim.claimedAt,
|
|
515
|
+
};
|
|
516
|
+
return {
|
|
517
|
+
...base,
|
|
518
|
+
status: result.status === 'already-running' ? 'already-dispatched' : 'dispatched',
|
|
519
|
+
agent: result.agent,
|
|
520
|
+
node: result.node,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
async function observeNotionClaim(task, input, sourceKey = task.sourceKey) {
|
|
524
|
+
if (!input.claims) {
|
|
525
|
+
throw new Error('dispatch requires a durable Agent Relay Notion claim store');
|
|
526
|
+
}
|
|
527
|
+
const claim = await input.claims.get(sourceKey);
|
|
528
|
+
if (claim?.sourceKey !== undefined && claim.sourceKey !== sourceKey) {
|
|
529
|
+
throw new Error('durable Notion claim does not match the requested source key');
|
|
530
|
+
}
|
|
531
|
+
if (claim?.digest !== undefined && claim.digest !== task.digest) {
|
|
532
|
+
throw new Error('durable Notion claim digest does not match the mounted spec');
|
|
533
|
+
}
|
|
534
|
+
return claim;
|
|
535
|
+
}
|
|
536
|
+
async function claimNotionTask(task, input, sourceKey = task.sourceKey) {
|
|
537
|
+
if (!input.claims) {
|
|
538
|
+
throw new Error('dispatch requires a durable Agent Relay Notion claim store');
|
|
539
|
+
}
|
|
540
|
+
const result = await input.claims.claim({
|
|
541
|
+
sourceKey,
|
|
542
|
+
digest: task.digest,
|
|
543
|
+
claimedAt: (input.now?.() ?? new Date()).toISOString(),
|
|
544
|
+
});
|
|
545
|
+
if (result.claim.sourceKey !== sourceKey) {
|
|
546
|
+
throw new Error('durable Notion claim does not match the requested source key');
|
|
547
|
+
}
|
|
548
|
+
if (result.claim.digest !== task.digest) {
|
|
549
|
+
throw new Error('durable Notion claim digest does not match the mounted spec');
|
|
550
|
+
}
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
function normalizedBootstrapSpec(bootstrap, pageId) {
|
|
554
|
+
const authorizedPageId = normalizeNotionPageId(bootstrap.authorizedPageId);
|
|
555
|
+
if (authorizedPageId !== pageId) {
|
|
556
|
+
throw new Error(`bootstrap authorization ${authorizedPageId} does not match mounted page ${pageId}`);
|
|
557
|
+
}
|
|
558
|
+
return { ...bootstrap, authorizedPageId };
|
|
559
|
+
}
|
|
560
|
+
function renderIssueBody(task, summary, delivery) {
|
|
561
|
+
return [
|
|
562
|
+
'## Factory intake',
|
|
563
|
+
'',
|
|
564
|
+
summary,
|
|
565
|
+
'',
|
|
566
|
+
...renderWorkerMountInstructions(task, delivery),
|
|
567
|
+
'',
|
|
568
|
+
'Treat the mounted page as the execution contract. Preserve every safety gate in it. Do not write back to Notion.',
|
|
569
|
+
`Before executing, SHA-256 hash the mounted file's UTF-8 bytes and refuse the task unless it matches \`${task.contentDigest}\`.`,
|
|
570
|
+
'',
|
|
571
|
+
`Source identity: \`notion:${task.pageId}\``,
|
|
572
|
+
`Source digest: \`${task.digest}\``,
|
|
573
|
+
sourceMarker(task.sourceKey),
|
|
574
|
+
...(delivery ? [contractDeliveryMarker(delivery)] : []),
|
|
575
|
+
].join('\n');
|
|
576
|
+
}
|
|
577
|
+
function renderWorkspaceTask(task, delivery) {
|
|
578
|
+
return [
|
|
579
|
+
task.title,
|
|
580
|
+
'',
|
|
581
|
+
task.summary,
|
|
582
|
+
'',
|
|
583
|
+
...renderWorkerMountInstructions(task, delivery),
|
|
584
|
+
`Before executing, SHA-256 hash that file's UTF-8 bytes and refuse the task unless it matches ${task.contentDigest}.`,
|
|
585
|
+
'Preserve every safety gate in that page. Do not write back to Notion.',
|
|
586
|
+
`Factory source: ${task.sourceKey}`,
|
|
587
|
+
`Source digest: ${task.digest}`,
|
|
588
|
+
].join('\n');
|
|
589
|
+
}
|
|
590
|
+
async function prepareContractDelivery(task, input, existing) {
|
|
591
|
+
if (input.manifest.workerMountTransport.kind === 'local')
|
|
592
|
+
return undefined;
|
|
593
|
+
if (!input.contracts) {
|
|
594
|
+
throw new Error('relay-channel worker mount transport requires an Agent Relay contract publisher');
|
|
595
|
+
}
|
|
596
|
+
const published = await input.contracts.publish({
|
|
597
|
+
pageId: task.pageId,
|
|
598
|
+
sourceKey: task.sourceKey,
|
|
599
|
+
content: task.content,
|
|
600
|
+
contentDigest: task.contentDigest,
|
|
601
|
+
});
|
|
602
|
+
const parsed = contractDeliverySchema.safeParse(published);
|
|
603
|
+
if (!parsed.success) {
|
|
604
|
+
const details = [...new Set(parsed.error.issues.map((issue) => issue.message))].join('; ');
|
|
605
|
+
throw new Error(`portable Notion contract publisher returned invalid delivery: ${details}`);
|
|
606
|
+
}
|
|
607
|
+
const delivery = parsed.data;
|
|
608
|
+
if (existing && !sameContractDelivery(existing, delivery)) {
|
|
609
|
+
throw new Error('portable Notion contract delivery changed after dispatch');
|
|
610
|
+
}
|
|
611
|
+
return delivery;
|
|
612
|
+
}
|
|
613
|
+
function renderWorkerMountInstructions(task, delivery) {
|
|
614
|
+
if (!delivery) {
|
|
615
|
+
return [
|
|
616
|
+
'The complete authorized spec is available to workers through the read-only Relayfile mount:',
|
|
617
|
+
`\`${task.workerSourcePath}\``,
|
|
618
|
+
];
|
|
619
|
+
}
|
|
620
|
+
return [
|
|
621
|
+
'The complete authorized spec was snapshotted from the read-only Notion mount into a workspace-private Agent Relay channel:',
|
|
622
|
+
`- channel: \`${delivery.channel}\``,
|
|
623
|
+
`- ordered message ids: \`${delivery.messageIds.join(',')}\``,
|
|
624
|
+
`- encoding: \`${delivery.encoding}\``,
|
|
625
|
+
'',
|
|
626
|
+
`Join that channel, concatenate the base64 payload from those exact messages in order, decode it to \`${task.workerSourcePath}\`, chmod the file 0444, and then apply the SHA-256 gate below. Never copy the contract to GitHub or another public surface.`,
|
|
627
|
+
];
|
|
628
|
+
}
|
|
629
|
+
function sameContractDelivery(left, right) {
|
|
630
|
+
return Boolean(left && left.kind === right.kind && left.channel === right.channel &&
|
|
631
|
+
left.encoding === right.encoding && left.messageIds.join('\0') === right.messageIds.join('\0'));
|
|
632
|
+
}
|
|
633
|
+
function contractDeliveryMarker(delivery) {
|
|
634
|
+
return `<!-- factory-notion-contract:${delivery.kind}:${delivery.channel}:${delivery.messageIds.join(',')} -->`;
|
|
635
|
+
}
|
|
636
|
+
function contractDeliveryFromBody(body) {
|
|
637
|
+
const match = /<!-- factory-notion-contract:relay-channel:([A-Za-z0-9._-]+):([A-Za-z0-9._-]+(?:,[A-Za-z0-9._-]+)*) -->/u.exec(body);
|
|
638
|
+
if (!match)
|
|
639
|
+
return undefined;
|
|
640
|
+
const parsed = contractDeliverySchema.safeParse({
|
|
641
|
+
kind: 'relay-channel',
|
|
642
|
+
channel: match[1],
|
|
643
|
+
messageIds: match[2]?.split(','),
|
|
644
|
+
encoding: 'base64-chunks-v1',
|
|
645
|
+
});
|
|
646
|
+
return parsed.success ? parsed.data : undefined;
|
|
647
|
+
}
|
|
648
|
+
function sourceMarker(sourceKey) {
|
|
649
|
+
return `<!-- factory-source:${sourceKey} -->`;
|
|
650
|
+
}
|
|
651
|
+
function digestFromBody(body) {
|
|
652
|
+
return /Source digest: `([0-9a-f]{64})`/u.exec(body)?.[1];
|
|
653
|
+
}
|
|
654
|
+
function requiredField(fields, name) {
|
|
655
|
+
const value = fields.get(name);
|
|
656
|
+
if (!value)
|
|
657
|
+
throw new Error(`Notion intake refused: Chief Spec requires ${name}`);
|
|
658
|
+
return value;
|
|
659
|
+
}
|
|
660
|
+
function splitField(value) {
|
|
661
|
+
return value?.split(',').map((entry) => entry.trim()).filter(Boolean) ?? [];
|
|
662
|
+
}
|
|
663
|
+
async function readIntakeState(path) {
|
|
664
|
+
try {
|
|
665
|
+
const common = {
|
|
666
|
+
digest: z.string().regex(/^[0-9a-f]{64}$/u),
|
|
667
|
+
dispatchedAt: z.string().datetime(),
|
|
668
|
+
delivery: contractDeliverySchema.optional(),
|
|
669
|
+
};
|
|
670
|
+
return z.object({
|
|
671
|
+
version: z.literal(1),
|
|
672
|
+
receipts: z.record(z.string(), z.discriminatedUnion('kind', [
|
|
673
|
+
z.object({
|
|
674
|
+
kind: z.literal('github'),
|
|
675
|
+
...common,
|
|
676
|
+
issue: z.object({ number: z.number().int().positive(), url: z.string().url() }),
|
|
677
|
+
}).strict(),
|
|
678
|
+
z.object({
|
|
679
|
+
kind: z.literal('workspace'),
|
|
680
|
+
...common,
|
|
681
|
+
agent: z.string().min(1),
|
|
682
|
+
node: z.string().min(1).optional(),
|
|
683
|
+
}).strict(),
|
|
684
|
+
])),
|
|
685
|
+
}).parse(JSON.parse(await readFile(path, 'utf8')));
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
const code = error.code;
|
|
689
|
+
if (code === 'ENOENT')
|
|
690
|
+
return { version: 1, receipts: {} };
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async function assertMountedTaskUnchanged(task) {
|
|
695
|
+
const content = await readFile(task.sourcePath, 'utf8');
|
|
696
|
+
if (contractDigest(content, task.authorizationDigestInput) !== task.digest) {
|
|
697
|
+
throw new Error('mounted spec changed while intake was planning dispatch');
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function contractDigest(content, authorizationDigestInput) {
|
|
701
|
+
const digest = createHash('sha256').update(content);
|
|
702
|
+
if (authorizationDigestInput)
|
|
703
|
+
digest.update('\0').update(authorizationDigestInput);
|
|
704
|
+
return digest.digest('hex');
|
|
705
|
+
}
|
|
706
|
+
function canonicalJson(value) {
|
|
707
|
+
if (Array.isArray(value))
|
|
708
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
709
|
+
if (value && typeof value === 'object') {
|
|
710
|
+
return `{${Object.entries(value)
|
|
711
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
712
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
|
|
713
|
+
.join(',')}}`;
|
|
714
|
+
}
|
|
715
|
+
return JSON.stringify(value) ?? 'null';
|
|
716
|
+
}
|
|
717
|
+
async function writeIntakeState(path, state) {
|
|
718
|
+
await mkdir(dirname(path), { recursive: true });
|
|
719
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
720
|
+
await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
721
|
+
await rename(temporaryPath, path);
|
|
722
|
+
}
|
|
723
|
+
async function runGh(args, input) {
|
|
724
|
+
return await new Promise((resolvePromise, reject) => {
|
|
725
|
+
const child = spawn('gh', args, {
|
|
726
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
727
|
+
timeout: 30_000,
|
|
728
|
+
killSignal: 'SIGKILL',
|
|
729
|
+
});
|
|
730
|
+
const stdout = [];
|
|
731
|
+
const stderr = [];
|
|
732
|
+
let stdoutSize = 0;
|
|
733
|
+
let stderrSize = 0;
|
|
734
|
+
let settled = false;
|
|
735
|
+
const fail = (error) => {
|
|
736
|
+
if (settled)
|
|
737
|
+
return;
|
|
738
|
+
settled = true;
|
|
739
|
+
reject(error);
|
|
740
|
+
};
|
|
741
|
+
child.stdout.on('data', (chunk) => {
|
|
742
|
+
stdoutSize += chunk.length;
|
|
743
|
+
if (stdoutSize <= 1024 * 1024)
|
|
744
|
+
stdout.push(chunk);
|
|
745
|
+
});
|
|
746
|
+
child.stderr.on('data', (chunk) => {
|
|
747
|
+
stderrSize += chunk.length;
|
|
748
|
+
if (stderrSize <= 1024 * 1024)
|
|
749
|
+
stderr.push(chunk);
|
|
750
|
+
});
|
|
751
|
+
child.once('error', fail);
|
|
752
|
+
child.stdin.once('error', (error) => {
|
|
753
|
+
if (error.code !== 'EPIPE')
|
|
754
|
+
fail(error);
|
|
755
|
+
});
|
|
756
|
+
child.once('close', (code) => {
|
|
757
|
+
if (settled)
|
|
758
|
+
return;
|
|
759
|
+
if (code !== 0) {
|
|
760
|
+
fail(new Error(`gh ${args.slice(0, 2).join(' ')} failed (${code ?? 'signal'}): ${Buffer.concat(stderr).toString('utf8').trim()}`));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (stdoutSize > 1024 * 1024 || stderrSize > 1024 * 1024) {
|
|
764
|
+
fail(new Error(`gh ${stdoutSize > 1024 * 1024 ? 'stdout' : 'stderr'} exceeded 1 MiB`));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
settled = true;
|
|
768
|
+
resolvePromise(Buffer.concat(stdout).toString('utf8'));
|
|
769
|
+
});
|
|
770
|
+
child.stdin.end(input);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
//# sourceMappingURL=notion.js.map
|