@agent-relay/factory 0.1.57 → 0.1.58
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 +139 -1
- package/dist/cli/fleet.d.ts +13 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +431 -103
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +1107 -348
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +67 -6
- package/dist/config/schema.js.map +1 -1
- 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/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/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/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/factory.d.ts +51 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +263 -17
- package/dist/orchestrator/factory.js.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 +58 -1
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +21 -0
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/state/file-state-store.d.ts +10 -1
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +91 -0
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts +10 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +43 -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 +133 -0
- package/dist/triage/schema.d.ts.map +1 -1
- package/dist/triage/schema.js +19 -0
- package/dist/triage/schema.js.map +1 -1
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +39 -0
- package/dist/writeback/github.js.map +1 -1
- package/package.json +10 -1
package/dist/cli/fleet.js
CHANGED
|
@@ -15,14 +15,18 @@ import { checkFeatureMap } from '../featuremap/index.js';
|
|
|
15
15
|
import { loadOrCreateFactoryInstanceId, resolveFactoryInstanceName } from '../observability/instance-identity.js';
|
|
16
16
|
import { ensureFactoryIntegrations, inspectFactoryIntegration, openIntegrationUrl, } from '../mount/relayfile-integration-preflight.js';
|
|
17
17
|
import { checkMountStaleness } from '../mount/relayfile-binary.js';
|
|
18
|
+
import { MountAuthScopeError } from '../mount/mount-auth-error.js';
|
|
19
|
+
import { resolveRelayWorkspaceKey } from '../fleet/relay-workspace-key.js';
|
|
20
|
+
import { GhCliIssuePublisher, RelayChannelNotionClaimStore, RelayChannelNotionContractPublisher, loadNotionIntakeManifest, runNotionIntake, } from '../intake/index.js';
|
|
18
21
|
const autoDetectedIssueSources = new WeakSet();
|
|
19
|
-
const CLONE_MOUNT_PREFLIGHT_CONCURRENCY = 4;
|
|
20
22
|
export async function runFleetCli(argv, deps = {}) {
|
|
21
23
|
const out = deps.stdout ?? process.stdout;
|
|
22
24
|
const err = deps.stderr ?? process.stderr;
|
|
23
25
|
let fleet;
|
|
24
26
|
let mount;
|
|
25
27
|
let reporter;
|
|
28
|
+
let notionContracts;
|
|
29
|
+
let notionClaims;
|
|
26
30
|
try {
|
|
27
31
|
if (argv.some(isHelpFlag)) {
|
|
28
32
|
out.write(helpText());
|
|
@@ -46,6 +50,81 @@ export async function runFleetCli(argv, deps = {}) {
|
|
|
46
50
|
await initializeFactory({ repo: command.repo, workspaceId: command.workspaceId, stdout: out, stderr: err });
|
|
47
51
|
return 0;
|
|
48
52
|
}
|
|
53
|
+
if (command.kind === 'notion-intake') {
|
|
54
|
+
const manifest = await loadNotionIntakeManifest(command.manifestPath);
|
|
55
|
+
if (!globals.dryRun) {
|
|
56
|
+
notionClaims = deps.notionClaims;
|
|
57
|
+
notionContracts = deps.notionContracts;
|
|
58
|
+
const needsWorkspaceKey = !notionClaims ||
|
|
59
|
+
(manifest.workerMountTransport.kind === 'relay-channel' && !notionContracts);
|
|
60
|
+
const workspaceKey = needsWorkspaceKey
|
|
61
|
+
? resolveRelayWorkspaceKey({
|
|
62
|
+
env: deps.env ?? process.env,
|
|
63
|
+
...(deps.env ? { activeWorkspaceKey: () => undefined } : {}),
|
|
64
|
+
})
|
|
65
|
+
: undefined;
|
|
66
|
+
if (needsWorkspaceKey && !workspaceKey) {
|
|
67
|
+
throw new Error('Notion dispatch requires an active Agent Relay workspace for its durable shared claim');
|
|
68
|
+
}
|
|
69
|
+
notionClaims ??= new RelayChannelNotionClaimStore({ workspaceKey: workspaceKey });
|
|
70
|
+
if (manifest.workerMountTransport.kind === 'relay-channel') {
|
|
71
|
+
notionContracts ??= new RelayChannelNotionContractPublisher({ workspaceKey: workspaceKey });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const workspace = {
|
|
75
|
+
find: async (name) => {
|
|
76
|
+
fleet ??= await buildFleet(globals, undefined, deps);
|
|
77
|
+
const running = (await fleet.roster()).agents.find((agent) => agent.name === name);
|
|
78
|
+
return running ? { agent: running.name, node: running.node } : undefined;
|
|
79
|
+
},
|
|
80
|
+
dispatch: async (task) => {
|
|
81
|
+
fleet ??= await buildFleet(globals, undefined, deps);
|
|
82
|
+
const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name);
|
|
83
|
+
if (running)
|
|
84
|
+
return { agent: running.name, node: running.node, status: 'already-running' };
|
|
85
|
+
const spawned = await fleet.spawn({
|
|
86
|
+
name: task.name,
|
|
87
|
+
capability: 'spawn:codex',
|
|
88
|
+
node: task.node ?? 'self',
|
|
89
|
+
task: task.task,
|
|
90
|
+
cwd: task.projectPath,
|
|
91
|
+
invocationId: task.invocationId,
|
|
92
|
+
});
|
|
93
|
+
fleet.preserveInfrastructureOnDispose?.();
|
|
94
|
+
return { agent: spawned.name, node: spawned.node, status: 'spawned' };
|
|
95
|
+
},
|
|
96
|
+
redispatch: async (task) => {
|
|
97
|
+
fleet ??= await buildFleet(globals, undefined, deps);
|
|
98
|
+
const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name);
|
|
99
|
+
if (running) {
|
|
100
|
+
await fleet.sendMessage({ to: `@${running.name}`, text: task.task, mode: 'steer' });
|
|
101
|
+
return { agent: running.name, node: running.node, status: 'updated-running' };
|
|
102
|
+
}
|
|
103
|
+
const spawned = await fleet.spawn({
|
|
104
|
+
name: task.name,
|
|
105
|
+
capability: 'spawn:codex',
|
|
106
|
+
node: task.node ?? 'self',
|
|
107
|
+
task: task.task,
|
|
108
|
+
cwd: task.projectPath,
|
|
109
|
+
invocationId: task.invocationId,
|
|
110
|
+
});
|
|
111
|
+
fleet.preserveInfrastructureOnDispose?.();
|
|
112
|
+
return { agent: spawned.name, node: spawned.node, status: 'respawned' };
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
const report = await runNotionIntake({
|
|
116
|
+
manifest,
|
|
117
|
+
dispatch: !globals.dryRun,
|
|
118
|
+
...(!globals.dryRun ? {
|
|
119
|
+
github: new GhCliIssuePublisher(),
|
|
120
|
+
workspace,
|
|
121
|
+
...(notionClaims ? { claims: notionClaims } : {}),
|
|
122
|
+
...(notionContracts ? { contracts: notionContracts } : {}),
|
|
123
|
+
} : {}),
|
|
124
|
+
});
|
|
125
|
+
writeJson(out, report);
|
|
126
|
+
return report.ok ? 0 : 1;
|
|
127
|
+
}
|
|
49
128
|
if (command.kind === 'factory-close-probe') {
|
|
50
129
|
// Manual close-probe remains strict; the daemon relaxes the title marker only after issue-synthetic classification.
|
|
51
130
|
let githubWrite;
|
|
@@ -238,27 +317,47 @@ export async function runFleetCli(argv, deps = {}) {
|
|
|
238
317
|
}
|
|
239
318
|
finally {
|
|
240
319
|
try {
|
|
241
|
-
|
|
320
|
+
try {
|
|
321
|
+
await notionClaims?.dispose?.();
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
err.write('[factory] warning: Notion claim store failed during shutdown\n');
|
|
325
|
+
}
|
|
242
326
|
}
|
|
243
327
|
finally {
|
|
244
328
|
try {
|
|
245
|
-
|
|
329
|
+
try {
|
|
330
|
+
await notionContracts?.dispose?.();
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
err.write('[factory] warning: Notion contract publisher failed during shutdown\n');
|
|
334
|
+
}
|
|
246
335
|
}
|
|
247
336
|
finally {
|
|
248
|
-
|
|
337
|
+
try {
|
|
338
|
+
await mount?.dispose?.();
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
249
341
|
try {
|
|
250
|
-
await
|
|
251
|
-
type: 'instance.stopping',
|
|
252
|
-
attributes: { component: 'cli', operation: 'stop' },
|
|
253
|
-
}));
|
|
254
|
-
await reporter.report(createFactoryCloudEventV1({
|
|
255
|
-
type: 'instance.stopped',
|
|
256
|
-
attributes: { component: 'cli', operation: 'stop' },
|
|
257
|
-
}));
|
|
258
|
-
await reporter.close?.({ deadlineMs: 2_000 });
|
|
342
|
+
await fleet?.dispose();
|
|
259
343
|
}
|
|
260
|
-
|
|
261
|
-
|
|
344
|
+
finally {
|
|
345
|
+
if (reporter) {
|
|
346
|
+
try {
|
|
347
|
+
await reporter.report(createFactoryCloudEventV1({
|
|
348
|
+
type: 'instance.stopping',
|
|
349
|
+
attributes: { component: 'cli', operation: 'stop' },
|
|
350
|
+
}));
|
|
351
|
+
await reporter.report(createFactoryCloudEventV1({
|
|
352
|
+
type: 'instance.stopped',
|
|
353
|
+
attributes: { component: 'cli', operation: 'stop' },
|
|
354
|
+
}));
|
|
355
|
+
await reporter.close?.({ deadlineMs: 2_000 });
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
err.write('[factory] warning: Cloud progress reporter failed during shutdown\n');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
262
361
|
}
|
|
263
362
|
}
|
|
264
363
|
}
|
|
@@ -279,8 +378,21 @@ export function parseFleetCommand(args) {
|
|
|
279
378
|
if (verb === 'featuremap') {
|
|
280
379
|
return parseFeatureMapCommand(rest);
|
|
281
380
|
}
|
|
381
|
+
if (verb === 'intake') {
|
|
382
|
+
return parseIntakeCommand(rest);
|
|
383
|
+
}
|
|
282
384
|
throw new Error(`Unknown factory command: ${verb}`);
|
|
283
385
|
}
|
|
386
|
+
function parseIntakeCommand(args) {
|
|
387
|
+
const [source, manifestPath, ...rest] = args;
|
|
388
|
+
if (source !== 'notion')
|
|
389
|
+
throw new Error('factory intake currently requires the notion source');
|
|
390
|
+
if (!manifestPath)
|
|
391
|
+
throw new Error('factory intake notion requires a manifest path');
|
|
392
|
+
if (rest.length > 0)
|
|
393
|
+
throw new Error(`Unexpected factory intake argument: ${rest[0]}`);
|
|
394
|
+
return { kind: 'notion-intake', manifestPath };
|
|
395
|
+
}
|
|
284
396
|
function parseFeatureMapCommand(args) {
|
|
285
397
|
const [action, ...flags] = args;
|
|
286
398
|
if (action !== 'check') {
|
|
@@ -377,15 +489,6 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
377
489
|
const debugMountRefreshes = (deps.env ?? process.env).FACTORY_LOG_LEVEL?.toLowerCase() === 'debug';
|
|
378
490
|
if (command.kind === 'factory') {
|
|
379
491
|
if (command.action === 'start') {
|
|
380
|
-
// Local mirrors are a writeback aid, not the source of truth for remote
|
|
381
|
-
// issue discovery. Start their SDK-backed supervisors immediately, but
|
|
382
|
-
// do not serialize durable recovery behind a stale checkout's readiness
|
|
383
|
-
// timeout. The mount client reports degradation and keeps retrying.
|
|
384
|
-
void warmStartPathMounts(mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes)
|
|
385
|
-
.catch((error) => {
|
|
386
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
387
|
-
mountStderr.write(`[factory] warning: background relayfile mount warmup failed: ${message}\n`);
|
|
388
|
-
});
|
|
389
492
|
const waiter = createStopSignalWaiter();
|
|
390
493
|
let stoppedBySignal = false;
|
|
391
494
|
const flushAndResolve = async (code) => {
|
|
@@ -396,6 +499,27 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
396
499
|
waiter.resolve(code);
|
|
397
500
|
}
|
|
398
501
|
};
|
|
502
|
+
// Once a workspace mirror is known, retain background stale-mount
|
|
503
|
+
// supervision so durable recovery is not serialized behind a refresh.
|
|
504
|
+
// If there is no registered root yet, however, wait for the single
|
|
505
|
+
// mount/admission fallback before Factory can dispatch: agents must not
|
|
506
|
+
// receive a provisional checkout-local `.integrations` path.
|
|
507
|
+
//
|
|
508
|
+
// A MountAuthScopeError is the exception: it is terminal (the cloud
|
|
509
|
+
// session lacks the filesystem scope the mount needs), so limping on
|
|
510
|
+
// would only spawn agents against a read-denied mirror. Fail fast with the
|
|
511
|
+
// remediation and resolve the command with a non-zero code.
|
|
512
|
+
const warmMount = () => warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
|
|
513
|
+
const handleWarmMountError = (error) => {
|
|
514
|
+
if (error instanceof MountAuthScopeError) {
|
|
515
|
+
mountStderr.write(`${error.message}\n`);
|
|
516
|
+
mountStderr.write('[factory] aborting startup: local mount cannot obtain its filesystem scopes.\n');
|
|
517
|
+
void flushAndResolve(1);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
521
|
+
mountStderr.write(`[factory] warning: background relayfile mount warmup failed: ${message}\n`);
|
|
522
|
+
};
|
|
399
523
|
const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
|
|
400
524
|
exit: (code) => {
|
|
401
525
|
stoppedBySignal = true;
|
|
@@ -407,6 +531,28 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
407
531
|
processLike: deps.stopSignalProcessLike,
|
|
408
532
|
});
|
|
409
533
|
try {
|
|
534
|
+
if (mount.getLocalMountRoot?.() === undefined) {
|
|
535
|
+
try {
|
|
536
|
+
const result = await warmMount();
|
|
537
|
+
if (!result.mounted) {
|
|
538
|
+
mountStderr.write('[factory] aborting startup: Relayfile workspace mirror could not be resolved.\n');
|
|
539
|
+
if (stoppedBySignal)
|
|
540
|
+
return await waiter.promise;
|
|
541
|
+
return 1;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
handleWarmMountError(error);
|
|
546
|
+
if (stoppedBySignal)
|
|
547
|
+
return await waiter.promise;
|
|
548
|
+
return 1;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
void warmMount().catch(handleWarmMountError);
|
|
553
|
+
}
|
|
554
|
+
if (stoppedBySignal)
|
|
555
|
+
return await waiter.promise;
|
|
410
556
|
await factory.start({ mode: command.mode });
|
|
411
557
|
const code = await (deps.waitForStopSignal?.() ?? waiter.promise);
|
|
412
558
|
return typeof code === 'number' ? code : 0;
|
|
@@ -419,12 +565,12 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
419
565
|
}
|
|
420
566
|
}
|
|
421
567
|
if (command.action === 'run-once') {
|
|
422
|
-
await
|
|
568
|
+
await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
|
|
423
569
|
writeJson(out, await factory.runOnce({ dryRun: globals.dryRun }));
|
|
424
570
|
return 0;
|
|
425
571
|
}
|
|
426
572
|
if (command.action === 'status') {
|
|
427
|
-
writeJson(out, factory.
|
|
573
|
+
writeJson(out, await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.heartbeatStaleMs));
|
|
428
574
|
return 0;
|
|
429
575
|
}
|
|
430
576
|
if (command.action === 'loop-status') {
|
|
@@ -441,13 +587,16 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
441
587
|
writeJson(out, { killed: heartbeat.pid, signal: 'SIGTERM' });
|
|
442
588
|
return 0;
|
|
443
589
|
}
|
|
444
|
-
await
|
|
590
|
+
await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
|
|
445
591
|
const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
|
|
446
592
|
processLike: deps.stopSignalProcessLike,
|
|
447
593
|
});
|
|
448
594
|
try {
|
|
449
595
|
const reports = await factory.runLoop({ dryRun: globals.dryRun });
|
|
450
|
-
writeJson(out, {
|
|
596
|
+
writeJson(out, {
|
|
597
|
+
reports,
|
|
598
|
+
status: await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.heartbeatStaleMs),
|
|
599
|
+
});
|
|
451
600
|
}
|
|
452
601
|
finally {
|
|
453
602
|
removeSignalHandlers();
|
|
@@ -461,8 +610,11 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
461
610
|
writeJson(out, result);
|
|
462
611
|
return result.ok ? 0 : 1;
|
|
463
612
|
}
|
|
464
|
-
const
|
|
465
|
-
const decision =
|
|
613
|
+
const resolved = await resolveIssueArg(mount, command.issue, config, async () => issueProjectionStatus(factory, mount, config));
|
|
614
|
+
const decision = {
|
|
615
|
+
...await factory.triageIssue(resolved.issue),
|
|
616
|
+
issueResolution: resolved.resolution,
|
|
617
|
+
};
|
|
466
618
|
if (command.kind === 'factory-triage') {
|
|
467
619
|
writeJson(out, decision);
|
|
468
620
|
return 0;
|
|
@@ -485,23 +637,18 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
485
637
|
writeJson(out, await factory.dispatch(decision, { dryRun: globals.dryRun }));
|
|
486
638
|
return 0;
|
|
487
639
|
}
|
|
488
|
-
async function warmStartPathMounts(mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug') {
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
writeMountRefreshSummary([...(daemonRefresh ? [daemonRefresh] : []), ...cloneRefreshes], stderr, debug);
|
|
640
|
+
async function warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug') {
|
|
641
|
+
const result = await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr);
|
|
642
|
+
writeMountRefreshSummary(result.refreshed ? [result.refreshed] : [], stderr, debug);
|
|
643
|
+
stderr.write(`[factory] Relayfile workspace mirror preflight: mounted=${result.mounted ? 1 : 0} ` +
|
|
644
|
+
`failed=${result.mounted ? 0 : 1} routedRepos=${new Set(Object.values(config.repos.byLabel)).size}\n`);
|
|
645
|
+
return result;
|
|
495
646
|
}
|
|
496
647
|
async function runStandaloneBabysitCommand(command, mount, fleet, config, globals, out, deps, workspaceId, acceptableMountIds) {
|
|
497
648
|
const repo = resolveStandaloneBabysitRepo(command.repo, config);
|
|
498
649
|
const clonePath = standaloneBabysitClonePath(repo, config);
|
|
499
650
|
const mountFn = resolveLocalMountFn(deps, mount);
|
|
500
|
-
|
|
501
|
-
await ensureStandaloneBabysitMount(mountFn, workspaceId, process.cwd(), mountOpts, deps.stderr);
|
|
502
|
-
if (clonePath && resolve(clonePath) !== resolve(process.cwd())) {
|
|
503
|
-
await ensureStandaloneBabysitMount(mountFn, workspaceId, clonePath, mountOpts, deps.stderr);
|
|
504
|
-
}
|
|
651
|
+
await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, deps.stderr);
|
|
505
652
|
const pr = await readStandalonePullRequest(mount, { repo, prNumber: command.prNumber, url: command.url }, deps.babysitPrGhRunner ?? defaultGhRunner);
|
|
506
653
|
const state = pr.state?.trim().toUpperCase();
|
|
507
654
|
if (pr.merged || state === 'MERGED') {
|
|
@@ -561,7 +708,7 @@ async function runStandaloneBabysitCommand(command, mount, fleet, config, global
|
|
|
561
708
|
maintainerCanModify: pr.maintainerCanModify,
|
|
562
709
|
},
|
|
563
710
|
standaloneBabysitter: { specSource },
|
|
564
|
-
integrationsMountRoot:
|
|
711
|
+
integrationsMountRoot: resolveIntegrationsMountRoot(mount),
|
|
565
712
|
testGuidance,
|
|
566
713
|
});
|
|
567
714
|
const receiptBase = {
|
|
@@ -597,16 +744,6 @@ async function runStandaloneBabysitCommand(command, mount, fleet, config, global
|
|
|
597
744
|
writeJson(out, { status: 'spawned', ...receiptBase, agent: spawned.name });
|
|
598
745
|
return 0;
|
|
599
746
|
}
|
|
600
|
-
async function ensureStandaloneBabysitMount(mountFn, workspaceId, startDir, options, stderr = process.stderr) {
|
|
601
|
-
try {
|
|
602
|
-
await mountFn(workspaceId, startDir, options);
|
|
603
|
-
}
|
|
604
|
-
catch (error) {
|
|
605
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
606
|
-
stderr.write(`[factory] warning: could not start relayfile mount for standalone babysitter at ${resolve(startDir)}; ` +
|
|
607
|
-
`the agent will use the GitHub CLI fallback: ${message}\n`);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
747
|
function resolveStandaloneBabysitRepo(repo, config) {
|
|
611
748
|
const configured = repo ?? config.repos.default;
|
|
612
749
|
if (!configured) {
|
|
@@ -636,50 +773,71 @@ function standaloneBabysitClonePath(repo, config) {
|
|
|
636
773
|
return undefined;
|
|
637
774
|
}
|
|
638
775
|
/**
|
|
639
|
-
* Ensures
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
* is not automatically accessible from a different directory, and agents need
|
|
643
|
-
* these paths for integration writebacks (Slack, GitHub, etc.).
|
|
776
|
+
* Ensures exactly one Relayfile mirror for the workspace. Agents receive this
|
|
777
|
+
* absolute path in their tasks, so routing more repositories never asks
|
|
778
|
+
* Relayfile to re-home the mirror into each checkout.
|
|
644
779
|
*/
|
|
645
|
-
async function
|
|
780
|
+
async function ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr = process.stderr) {
|
|
646
781
|
const mountOpts = { acceptableWorkspaceIds: acceptableMountIds };
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
const mountNext = async () => {
|
|
653
|
-
while (nextIndex < clonePaths.length) {
|
|
654
|
-
const resolved = clonePaths[nextIndex++];
|
|
655
|
-
const refreshed = await ensureMountPath(mountFn, workspaceId, resolved, mountOpts, stderr);
|
|
656
|
-
if (refreshed)
|
|
657
|
-
refreshedStaleMounts.push(refreshed);
|
|
658
|
-
}
|
|
659
|
-
};
|
|
660
|
-
await Promise.all(Array.from({ length: Math.min(CLONE_MOUNT_PREFLIGHT_CONCURRENCY, clonePaths.length) }, mountNext));
|
|
661
|
-
if (reportSummary)
|
|
662
|
-
writeMountRefreshSummary(refreshedStaleMounts, stderr, debug);
|
|
663
|
-
return refreshedStaleMounts;
|
|
664
|
-
}
|
|
665
|
-
async function ensureMountPath(mountFn, workspaceId, path, mountOpts, stderr) {
|
|
666
|
-
const resolved = resolve(path);
|
|
667
|
-
const statePath = join(resolved, '.integrations', '.relay', 'state.json');
|
|
782
|
+
const localDir = mount.getLocalMountRoot?.();
|
|
783
|
+
return ensureMountPath(mountFn, workspaceId, localDir ? dirname(localDir) : process.cwd(), mountOpts, stderr, localDir);
|
|
784
|
+
}
|
|
785
|
+
async function ensureMountPath(mountFn, workspaceId, path, mountOpts, stderr, localDir = join(resolve(path), '.integrations')) {
|
|
786
|
+
const statePath = join(localDir, '.relay', 'state.json');
|
|
668
787
|
const staleBefore = checkMountStaleness(statePath, workspaceId, mountOpts.acceptableWorkspaceIds);
|
|
669
788
|
try {
|
|
670
|
-
await mountFn(workspaceId,
|
|
789
|
+
await mountFn(workspaceId, resolve(path), {
|
|
671
790
|
...mountOpts,
|
|
672
791
|
...(staleBefore.stale ? { suppressStaleRefreshLogs: true } : {}),
|
|
673
792
|
});
|
|
674
793
|
if (staleBefore.stale && !checkMountStaleness(statePath, workspaceId, mountOpts.acceptableWorkspaceIds).stale) {
|
|
675
|
-
return { path:
|
|
794
|
+
return { mounted: true, refreshed: { path: localDir, reason: staleBefore.reason } };
|
|
676
795
|
}
|
|
796
|
+
return { mounted: true };
|
|
677
797
|
}
|
|
678
798
|
catch (error) {
|
|
799
|
+
// A scope shortfall is terminal and identical across every clone path;
|
|
800
|
+
// propagate it so startup fails fast with one remediation instead of
|
|
801
|
+
// logging the same unfixable warning per path.
|
|
802
|
+
if (error instanceof MountAuthScopeError)
|
|
803
|
+
throw error;
|
|
679
804
|
const message = error instanceof Error ? error.message : String(error);
|
|
680
|
-
stderr.write(`[factory] warning: could not start
|
|
805
|
+
stderr.write(`[factory] warning: could not start Relayfile workspace mirror at ${localDir}: ${message}\n`);
|
|
681
806
|
}
|
|
682
|
-
return
|
|
807
|
+
return { mounted: false };
|
|
808
|
+
}
|
|
809
|
+
function resolveIntegrationsMountRoot(mount) {
|
|
810
|
+
return mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
|
|
811
|
+
}
|
|
812
|
+
async function factoryStatusWithMountHealth(factory, mount, heartbeatPath, heartbeatStaleMs) {
|
|
813
|
+
const status = factory.status();
|
|
814
|
+
const heartbeat = await readFactoryLoopHeartbeat(heartbeatPath);
|
|
815
|
+
const liveness = checkFactoryLoopLiveness(heartbeat, { staleMs: heartbeatStaleMs });
|
|
816
|
+
const eventListener = liveness.ok
|
|
817
|
+
? heartbeat?.eventListener ?? {
|
|
818
|
+
state: 'unknown',
|
|
819
|
+
reason: 'running daemon heartbeat does not report event listener state',
|
|
820
|
+
}
|
|
821
|
+
: {
|
|
822
|
+
state: 'not-listening',
|
|
823
|
+
reason: liveness.reason,
|
|
824
|
+
};
|
|
825
|
+
const health = mount.getLocalMountHealth?.();
|
|
826
|
+
if (!health)
|
|
827
|
+
return { ...status, eventListener };
|
|
828
|
+
return {
|
|
829
|
+
...status,
|
|
830
|
+
eventListener,
|
|
831
|
+
localMountDegraded: health.degraded,
|
|
832
|
+
...(health.reason ? { localMountDegradedReason: health.reason } : {}),
|
|
833
|
+
...(health.localDir ? { localMountRoot: health.localDir } : {}),
|
|
834
|
+
localMountEventFeed: {
|
|
835
|
+
state: health.degraded ? 'degraded' : 'healthy',
|
|
836
|
+
livenessSignal: '.integrations/.relay/state.json',
|
|
837
|
+
...(health.reason ? { reason: health.reason } : {}),
|
|
838
|
+
...(health.localDir ? { root: health.localDir } : {}),
|
|
839
|
+
},
|
|
840
|
+
};
|
|
683
841
|
}
|
|
684
842
|
function writeMountRefreshSummary(refreshedStaleMounts, stderr, debug) {
|
|
685
843
|
if (debug) {
|
|
@@ -1018,9 +1176,28 @@ async function prepareFactoryIntegrations(command, mount, config, globals, deps,
|
|
|
1018
1176
|
if (providers.length === 0)
|
|
1019
1177
|
return;
|
|
1020
1178
|
const dryRun = globals.dryRun || command.kind === 'factory-canary';
|
|
1179
|
+
const providersToEnsure = [];
|
|
1180
|
+
for (const provider of providers) {
|
|
1181
|
+
const observation = observed.get(provider) ?? await inspectFactoryIntegration(connections, provider);
|
|
1182
|
+
observed.set(provider, observation);
|
|
1183
|
+
if (provider === 'github' &&
|
|
1184
|
+
observation.kind === 'connected-not-ready' &&
|
|
1185
|
+
mount.githubRead &&
|
|
1186
|
+
(command.kind === 'factory-triage' || command.kind === 'factory-dispatch')) {
|
|
1187
|
+
const details = [observation.state, observation.initialSyncState]
|
|
1188
|
+
.filter((value) => Boolean(value))
|
|
1189
|
+
.join(', ');
|
|
1190
|
+
err.write(`[factory] warning: GitHub projection is not ready${details ? ` (${details})` : ''}; ` +
|
|
1191
|
+
'targeted resolution remains projection-first and will use the GitHub API fallback only for a miss.\n');
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
providersToEnsure.push(provider);
|
|
1195
|
+
}
|
|
1196
|
+
if (providersToEnsure.length === 0)
|
|
1197
|
+
return;
|
|
1021
1198
|
await ensureFactoryIntegrations({
|
|
1022
1199
|
connections,
|
|
1023
|
-
providers,
|
|
1200
|
+
providers: providersToEnsure,
|
|
1024
1201
|
workspaceId,
|
|
1025
1202
|
interactive: !dryRun && (deps.isInteractive?.() ?? Boolean(process.stdin.isTTY && process.stderr.isTTY)),
|
|
1026
1203
|
dryRun,
|
|
@@ -1118,6 +1295,7 @@ async function buildMount(loaded, deps, observability = {}) {
|
|
|
1118
1295
|
let mount;
|
|
1119
1296
|
mount = await (deps.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({
|
|
1120
1297
|
workspaceId: loaded.config.workspaceId,
|
|
1298
|
+
localMountRoot: loaded.config.localMountRoot,
|
|
1121
1299
|
logger: observability.logger,
|
|
1122
1300
|
onLocalMountHealth: observability.onLocalMountHealth,
|
|
1123
1301
|
isAllowedDraft: (path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, loaded.config),
|
|
@@ -1170,19 +1348,52 @@ const scopeIssueFromDraftContent = (content) => ({
|
|
|
1170
1348
|
raw: asRecord(content),
|
|
1171
1349
|
});
|
|
1172
1350
|
async function readIssueArg(mount, issueArg, config) {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1351
|
+
return (await resolveIssueArg(mount, issueArg, config)).issue;
|
|
1352
|
+
}
|
|
1353
|
+
async function resolveIssueArg(mount, issueArg, config, projectionStatus) {
|
|
1354
|
+
const explicitPath = issueArg.startsWith('/');
|
|
1355
|
+
const path = explicitPath ? issueArg : await findIssuePath(mount, issueArg, config);
|
|
1356
|
+
if (path) {
|
|
1357
|
+
const issue = githubIssuePathParts(path)
|
|
1358
|
+
? parseGithubFactoryIssue(path, (await mount.readFile(path)).content)
|
|
1359
|
+
: await readLinearIssueWithCanonicalFallback(mount, path);
|
|
1360
|
+
return {
|
|
1361
|
+
issue,
|
|
1362
|
+
resolution: {
|
|
1363
|
+
source: 'relayfile-projection',
|
|
1364
|
+
detail: 'Resolved from the preferred Relayfile projection.',
|
|
1365
|
+
projection: { outcome: 'matched' },
|
|
1366
|
+
},
|
|
1367
|
+
};
|
|
1176
1368
|
}
|
|
1177
|
-
|
|
1369
|
+
const selector = parseGithubIssueSelector(issueArg, config);
|
|
1370
|
+
const projection = projectionStatus
|
|
1371
|
+
? await projectionStatus()
|
|
1372
|
+
: projectionStatusFromMount(mount);
|
|
1373
|
+
const unavailableReason = githubProjectionUnavailableReason(projection);
|
|
1374
|
+
if (!unavailableReason) {
|
|
1375
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: found 0 matches in the healthy Relayfile projection; ` +
|
|
1376
|
+
'the GitHub API fallback was not used');
|
|
1377
|
+
}
|
|
1378
|
+
const fallback = await findGithubIssueThroughConnection(mount, selector, config, issueArg);
|
|
1379
|
+
if (!fallback) {
|
|
1380
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: found 0 matches in the projection and GitHub API`);
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
issue: parseGithubFactoryIssue(fallback.path, fallback.content),
|
|
1384
|
+
resolution: {
|
|
1385
|
+
source: 'github-api-fallback',
|
|
1386
|
+
repo: fallback.repo,
|
|
1387
|
+
detail: `Relayfile projection could not answer (${unavailableReason}); resolved authoritatively through the GitHub API fallback.`,
|
|
1388
|
+
projection,
|
|
1389
|
+
},
|
|
1390
|
+
};
|
|
1178
1391
|
}
|
|
1179
1392
|
async function findIssuePath(mount, key, config) {
|
|
1180
1393
|
if (config.issueSource === 'github') {
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
}
|
|
1185
|
-
const configuredRepos = configuredGithubIssueRepos(config);
|
|
1394
|
+
const selector = parseGithubIssueSelector(key, config);
|
|
1395
|
+
const number = selector.number;
|
|
1396
|
+
const configuredRepos = selector.repo ? [selector.repo] : configuredGithubIssueRepos(config);
|
|
1186
1397
|
if (configuredRepos.length === 0 && hasConfiguredGithubIssueRoutes(config)) {
|
|
1187
1398
|
throw new Error(`${githubIssueResolutionError(config, key)}: configured repository routes do not resolve to owner/repo; ` +
|
|
1188
1399
|
'set repos.default to owner/repo or map its label in repos.byLabel');
|
|
@@ -1206,7 +1417,7 @@ async function findIssuePath(mount, key, config) {
|
|
|
1206
1417
|
})
|
|
1207
1418
|
.sort((left, right) => githubIssuePathPreference(left) - githubIssuePathPreference(right) || left.localeCompare(right));
|
|
1208
1419
|
if (matches.length === 0) {
|
|
1209
|
-
|
|
1420
|
+
return undefined;
|
|
1210
1421
|
}
|
|
1211
1422
|
const matchesByRepo = new Map();
|
|
1212
1423
|
for (const path of matches) {
|
|
@@ -1232,14 +1443,106 @@ async function findIssuePath(mount, key, config) {
|
|
|
1232
1443
|
}
|
|
1233
1444
|
return matches[0];
|
|
1234
1445
|
}
|
|
1235
|
-
function
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
:
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1446
|
+
async function findGithubIssueThroughConnection(mount, selector, config, issueArg) {
|
|
1447
|
+
const github = mount.githubRead;
|
|
1448
|
+
if (!github) {
|
|
1449
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: projection cannot answer and the GitHub API fallback is unavailable`);
|
|
1450
|
+
}
|
|
1451
|
+
const repos = selector.repo ? [selector.repo] : configuredGithubIssueRepos(config);
|
|
1452
|
+
if (repos.length === 0) {
|
|
1453
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: projection cannot answer and no configured owner/repo is available for the GitHub API fallback`);
|
|
1454
|
+
}
|
|
1455
|
+
const lookups = await Promise.all(repos.map((repo) => github.getIssue(repo, selector.number)));
|
|
1456
|
+
const found = lookups.filter((lookup) => lookup.outcome === 'found');
|
|
1457
|
+
const indeterminate = lookups.some((lookup) => lookup.outcome === 'indeterminate');
|
|
1458
|
+
if (found.length === 0) {
|
|
1459
|
+
if (indeterminate) {
|
|
1460
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: the projection could not answer and the GitHub API fallback ` +
|
|
1461
|
+
'could not determine whether the issue exists (one or more configured repositories are not visible without authentication)');
|
|
1462
|
+
}
|
|
1463
|
+
return undefined;
|
|
1464
|
+
}
|
|
1465
|
+
if (!selector.repo && !config.repos.default) {
|
|
1466
|
+
if (found.length > 1) {
|
|
1467
|
+
const matchedRepos = found.map((match) => match.issue.repo).sort((left, right) => left.localeCompare(right));
|
|
1468
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: GitHub API matches multiple repositories (${matchedRepos.join(', ')}); ` +
|
|
1469
|
+
'set repos.default or pass a repo-qualified argument');
|
|
1470
|
+
}
|
|
1471
|
+
if (indeterminate) {
|
|
1472
|
+
// A single confirmed match is not the same as a unique one: at least
|
|
1473
|
+
// one other configured repository could not be checked, so a same-
|
|
1474
|
+
// numbered issue could exist there too. Refuse rather than silently
|
|
1475
|
+
// dispatch to whichever repo happened to answer.
|
|
1476
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: GitHub API found a match in ${found[0].issue.repo} but could not ` +
|
|
1477
|
+
'confirm it is unique because one or more other configured repositories could not be checked without authentication; ' +
|
|
1478
|
+
'set repos.default or pass a repo-qualified argument');
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
return found[0].issue;
|
|
1482
|
+
}
|
|
1483
|
+
async function issueProjectionStatus(factory, mount, config) {
|
|
1484
|
+
const status = await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.heartbeatStaleMs);
|
|
1485
|
+
const githubConnection = mount.integrationConnections
|
|
1486
|
+
? await mount.integrationConnections.getStatus('github')
|
|
1487
|
+
: undefined;
|
|
1488
|
+
return {
|
|
1489
|
+
outcome: 'no-match',
|
|
1490
|
+
...(status.localMountDegraded !== undefined ? { localMountDegraded: status.localMountDegraded } : {}),
|
|
1491
|
+
...(status.localMountDegradedReason ? { localMountDegradedReason: status.localMountDegradedReason } : {}),
|
|
1492
|
+
...(status.eventListener ? { eventListener: status.eventListener } : {}),
|
|
1493
|
+
...(githubConnection ? { githubConnection } : {}),
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
function projectionStatusFromMount(mount) {
|
|
1497
|
+
const health = mount.getLocalMountHealth?.();
|
|
1498
|
+
return {
|
|
1499
|
+
outcome: 'no-match',
|
|
1500
|
+
...(health ? { localMountDegraded: health.degraded } : {}),
|
|
1501
|
+
...(health?.reason ? { localMountDegradedReason: health.reason } : {}),
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
function githubProjectionUnavailableReason(projection) {
|
|
1505
|
+
if (projection.githubConnection && !projection.githubConnection.ready) {
|
|
1506
|
+
const detail = [projection.githubConnection.state, projection.githubConnection.initialSyncState]
|
|
1507
|
+
.filter((value) => Boolean(value))
|
|
1508
|
+
.join(', ');
|
|
1509
|
+
return `GitHub projection connection is not ready${detail ? ` (${detail})` : ''}`;
|
|
1510
|
+
}
|
|
1511
|
+
if (projection.localMountDegraded) {
|
|
1512
|
+
return projection.localMountDegradedReason ?? 'local mount is degraded';
|
|
1513
|
+
}
|
|
1514
|
+
if (projection.eventListener && !['subscribed', 'polling'].includes(projection.eventListener.state)) {
|
|
1515
|
+
return projection.eventListener.reason ?? `event listener is ${projection.eventListener.state}`;
|
|
1516
|
+
}
|
|
1517
|
+
return undefined;
|
|
1518
|
+
}
|
|
1519
|
+
export function parseGithubIssueSelector(key, config) {
|
|
1520
|
+
const qualified = key.match(/^([^#]+)#([1-9]\d*)$/u);
|
|
1521
|
+
const bare = key.match(/^#?([1-9]\d*)$/u);
|
|
1522
|
+
const number = Number(qualified?.[2] ?? bare?.[1]);
|
|
1523
|
+
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
1524
|
+
throw new Error(`${githubIssueResolutionError(config, key)}: expected a positive issue number or repo-qualified reference (repo#number)`);
|
|
1525
|
+
}
|
|
1526
|
+
if (!qualified)
|
|
1527
|
+
return { number };
|
|
1528
|
+
const requested = qualified[1];
|
|
1529
|
+
const configured = allConfiguredGithubIssueRepos(config);
|
|
1530
|
+
// Resolve what the caller typed through the exact same canonicalization
|
|
1531
|
+
// every configured route already goes through (label mapping, org
|
|
1532
|
+
// prefixing, canonical-route lookup) instead of a second, ad-hoc
|
|
1533
|
+
// expansion — three review rounds each found a different normalization
|
|
1534
|
+
// gap (default-only, org-only, bare-label-vs-normalized) in a hand-rolled
|
|
1535
|
+
// comparison here. Comparison is only ever normalized against normalized.
|
|
1536
|
+
const [resolved] = resolveGithubIssueRepoCandidates(config, [requested]);
|
|
1537
|
+
const repo = resolved
|
|
1538
|
+
? configured.find((candidate) => candidate.toLowerCase() === resolved.toLowerCase())
|
|
1539
|
+
: undefined;
|
|
1540
|
+
if (!repo) {
|
|
1541
|
+
throw new Error(`${githubIssueResolutionError(config, key)}: repository ${requested} is not one of the configured Factory routes`);
|
|
1542
|
+
}
|
|
1543
|
+
return { number, repo };
|
|
1544
|
+
}
|
|
1545
|
+
function resolveGithubIssueRepoCandidates(config, candidates) {
|
|
1243
1546
|
const repos = new Map();
|
|
1244
1547
|
const routedRepos = [
|
|
1245
1548
|
...Object.values(config.repos.byLabel),
|
|
@@ -1260,6 +1563,30 @@ function configuredGithubIssueRepos(config) {
|
|
|
1260
1563
|
}
|
|
1261
1564
|
return [...repos.values()];
|
|
1262
1565
|
}
|
|
1566
|
+
/** Bare-number resolution stays default-only: when repos.default is set, a
|
|
1567
|
+
* bare issue number resolves against that single repo, not every route. */
|
|
1568
|
+
function configuredGithubIssueRepos(config) {
|
|
1569
|
+
const candidates = config.repos.default
|
|
1570
|
+
? [config.repos.default]
|
|
1571
|
+
: [
|
|
1572
|
+
...Object.values(config.repos.byLabel),
|
|
1573
|
+
...Object.values(config.repos.byProject),
|
|
1574
|
+
...config.repos.keywordRules.map((rule) => rule.repo),
|
|
1575
|
+
];
|
|
1576
|
+
return resolveGithubIssueRepoCandidates(config, candidates);
|
|
1577
|
+
}
|
|
1578
|
+
/** Qualified `repo#number` selectors must validate against every configured
|
|
1579
|
+
* route, not just repos.default — a route reachable only through byLabel,
|
|
1580
|
+
* byProject, or keywordRules is still a valid dispatch target. */
|
|
1581
|
+
function allConfiguredGithubIssueRepos(config) {
|
|
1582
|
+
const candidates = [
|
|
1583
|
+
...(config.repos.default ? [config.repos.default] : []),
|
|
1584
|
+
...Object.values(config.repos.byLabel),
|
|
1585
|
+
...Object.values(config.repos.byProject),
|
|
1586
|
+
...config.repos.keywordRules.map((rule) => rule.repo),
|
|
1587
|
+
];
|
|
1588
|
+
return resolveGithubIssueRepoCandidates(config, candidates);
|
|
1589
|
+
}
|
|
1263
1590
|
function hasConfiguredGithubIssueRoutes(config) {
|
|
1264
1591
|
return Boolean(config.repos.default ||
|
|
1265
1592
|
Object.keys(config.repos.byLabel).length > 0 ||
|
|
@@ -1449,6 +1776,7 @@ Commands:
|
|
|
1449
1776
|
babysit <PR|URL> Shepherd an existing open PR to green
|
|
1450
1777
|
close-probe <PR> Probe/close a PR for an issue
|
|
1451
1778
|
featuremap check Validate .agentworkforce/features/manifest.yaml
|
|
1779
|
+
intake notion <file> Normalize mounted Notion specs into Factory work
|
|
1452
1780
|
fleet <command> Low-level fleet commands: spawn, roster, release
|
|
1453
1781
|
|
|
1454
1782
|
Options:
|