@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
package/dist/cli/fleet.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import readline from 'node:readline/promises';
|
|
6
6
|
import { ensureCloudSession } from '@agent-relay/cloud';
|
|
7
7
|
import { stringifyLogValue } from '../logging.js';
|
|
8
8
|
import { resolveLocalFactoryConfig } from '../config/local-clone-paths.js';
|
|
9
9
|
import { initializeFactory } from './init.js';
|
|
10
|
-
import { FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, createFactoryCloudEventV1, ensureRelayBroker, FactoryCloudReporter, FileFactoryCloudEventOutbox, defaultGhRunner, explicitLinkedIssueKey, githubIssuePathParts, githubWatchStatePath, isInFactoryScope, parseGithubFactoryIssue, publishFactoryMountHealth, parseOwnedBrokerAgentExitTimeoutMs, parseStandaloneBabysitTarget, readStandalonePullRequest, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, reapFactoryEnvironmentsOnce, readFactoryLoopHeartbeat, resolveFactoryStates, stateResolutionFromIds, standaloneBabysitterAgentName, renderAgentTask, resolveFactoryWorkspace, } from '../index.js';
|
|
10
|
+
import { FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, createFactoryCloudEventV1, ensureRelayBroker, FactoryCloudReporter, FileFactoryCloudEventOutbox, defaultGhRunner, explicitLinkedIssueKey, githubIssuePathParts, githubWatchStatePath, isInFactoryScope, parseGithubFactoryIssue, publishFactoryMountHealth, parseOwnedBrokerAgentExitTimeoutMs, parseStandaloneBabysitTarget, readStandalonePullRequest, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, reapFactoryEnvironmentsOnce, readFactoryLoopHeartbeat, readFactoryInFlightRegistry, resolveFactoryStates, stateResolutionFromIds, standaloneBabysitterAgentName, renderAgentTask, resolveFactoryWorkspace, } from '../index.js';
|
|
11
11
|
import { resolveTestGuidance } from '../dispatch/test-guidance.js';
|
|
12
12
|
import { FakeFleetClient, FakeMountClient } from '../testing/index.js';
|
|
13
13
|
import { GitAgentWorktreeManager } from '../git/agent-worktree.js';
|
|
@@ -15,14 +15,19 @@ 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 { isMeaningfullyBehind, readFactoryVersionInfo, } from '../version-info.js';
|
|
21
|
+
import { GhCliIssuePublisher, RelayChannelNotionClaimStore, RelayChannelNotionContractPublisher, loadNotionIntakeManifest, runNotionIntake, } from '../intake/index.js';
|
|
18
22
|
const autoDetectedIssueSources = new WeakSet();
|
|
19
|
-
const CLONE_MOUNT_PREFLIGHT_CONCURRENCY = 4;
|
|
20
23
|
export async function runFleetCli(argv, deps = {}) {
|
|
21
24
|
const out = deps.stdout ?? process.stdout;
|
|
22
25
|
const err = deps.stderr ?? process.stderr;
|
|
23
26
|
let fleet;
|
|
24
27
|
let mount;
|
|
25
28
|
let reporter;
|
|
29
|
+
let notionContracts;
|
|
30
|
+
let notionClaims;
|
|
26
31
|
try {
|
|
27
32
|
if (argv.some(isHelpFlag)) {
|
|
28
33
|
out.write(helpText());
|
|
@@ -46,6 +51,81 @@ export async function runFleetCli(argv, deps = {}) {
|
|
|
46
51
|
await initializeFactory({ repo: command.repo, workspaceId: command.workspaceId, stdout: out, stderr: err });
|
|
47
52
|
return 0;
|
|
48
53
|
}
|
|
54
|
+
if (command.kind === 'notion-intake') {
|
|
55
|
+
const manifest = await loadNotionIntakeManifest(command.manifestPath);
|
|
56
|
+
if (!globals.dryRun) {
|
|
57
|
+
notionClaims = deps.notionClaims;
|
|
58
|
+
notionContracts = deps.notionContracts;
|
|
59
|
+
const needsWorkspaceKey = !notionClaims ||
|
|
60
|
+
(manifest.workerMountTransport.kind === 'relay-channel' && !notionContracts);
|
|
61
|
+
const workspaceKey = needsWorkspaceKey
|
|
62
|
+
? resolveRelayWorkspaceKey({
|
|
63
|
+
env: deps.env ?? process.env,
|
|
64
|
+
...(deps.env ? { activeWorkspaceKey: () => undefined } : {}),
|
|
65
|
+
})
|
|
66
|
+
: undefined;
|
|
67
|
+
if (needsWorkspaceKey && !workspaceKey) {
|
|
68
|
+
throw new Error('Notion dispatch requires an active Agent Relay workspace for its durable shared claim');
|
|
69
|
+
}
|
|
70
|
+
notionClaims ??= new RelayChannelNotionClaimStore({ workspaceKey: workspaceKey });
|
|
71
|
+
if (manifest.workerMountTransport.kind === 'relay-channel') {
|
|
72
|
+
notionContracts ??= new RelayChannelNotionContractPublisher({ workspaceKey: workspaceKey });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const workspace = {
|
|
76
|
+
find: async (name) => {
|
|
77
|
+
fleet ??= await buildFleet(globals, undefined, deps);
|
|
78
|
+
const running = (await fleet.roster()).agents.find((agent) => agent.name === name);
|
|
79
|
+
return running ? { agent: running.name, node: running.node } : undefined;
|
|
80
|
+
},
|
|
81
|
+
dispatch: async (task) => {
|
|
82
|
+
fleet ??= await buildFleet(globals, undefined, deps);
|
|
83
|
+
const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name);
|
|
84
|
+
if (running)
|
|
85
|
+
return { agent: running.name, node: running.node, status: 'already-running' };
|
|
86
|
+
const spawned = await fleet.spawn({
|
|
87
|
+
name: task.name,
|
|
88
|
+
capability: 'spawn:codex',
|
|
89
|
+
node: task.node ?? 'self',
|
|
90
|
+
task: task.task,
|
|
91
|
+
cwd: task.projectPath,
|
|
92
|
+
invocationId: task.invocationId,
|
|
93
|
+
});
|
|
94
|
+
fleet.preserveInfrastructureOnDispose?.();
|
|
95
|
+
return { agent: spawned.name, node: spawned.node, status: 'spawned' };
|
|
96
|
+
},
|
|
97
|
+
redispatch: async (task) => {
|
|
98
|
+
fleet ??= await buildFleet(globals, undefined, deps);
|
|
99
|
+
const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name);
|
|
100
|
+
if (running) {
|
|
101
|
+
await fleet.sendMessage({ to: `@${running.name}`, text: task.task, mode: 'steer' });
|
|
102
|
+
return { agent: running.name, node: running.node, status: 'updated-running' };
|
|
103
|
+
}
|
|
104
|
+
const spawned = await fleet.spawn({
|
|
105
|
+
name: task.name,
|
|
106
|
+
capability: 'spawn:codex',
|
|
107
|
+
node: task.node ?? 'self',
|
|
108
|
+
task: task.task,
|
|
109
|
+
cwd: task.projectPath,
|
|
110
|
+
invocationId: task.invocationId,
|
|
111
|
+
});
|
|
112
|
+
fleet.preserveInfrastructureOnDispose?.();
|
|
113
|
+
return { agent: spawned.name, node: spawned.node, status: 'respawned' };
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const report = await runNotionIntake({
|
|
117
|
+
manifest,
|
|
118
|
+
dispatch: !globals.dryRun,
|
|
119
|
+
...(!globals.dryRun ? {
|
|
120
|
+
github: new GhCliIssuePublisher(),
|
|
121
|
+
workspace,
|
|
122
|
+
...(notionClaims ? { claims: notionClaims } : {}),
|
|
123
|
+
...(notionContracts ? { contracts: notionContracts } : {}),
|
|
124
|
+
} : {}),
|
|
125
|
+
});
|
|
126
|
+
writeJson(out, report);
|
|
127
|
+
return report.ok ? 0 : 1;
|
|
128
|
+
}
|
|
49
129
|
if (command.kind === 'factory-close-probe') {
|
|
50
130
|
// Manual close-probe remains strict; the daemon relaxes the title marker only after issue-synthetic classification.
|
|
51
131
|
let githubWrite;
|
|
@@ -238,27 +318,47 @@ export async function runFleetCli(argv, deps = {}) {
|
|
|
238
318
|
}
|
|
239
319
|
finally {
|
|
240
320
|
try {
|
|
241
|
-
|
|
321
|
+
try {
|
|
322
|
+
await notionClaims?.dispose?.();
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
err.write('[factory] warning: Notion claim store failed during shutdown\n');
|
|
326
|
+
}
|
|
242
327
|
}
|
|
243
328
|
finally {
|
|
244
329
|
try {
|
|
245
|
-
|
|
330
|
+
try {
|
|
331
|
+
await notionContracts?.dispose?.();
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
err.write('[factory] warning: Notion contract publisher failed during shutdown\n');
|
|
335
|
+
}
|
|
246
336
|
}
|
|
247
337
|
finally {
|
|
248
|
-
|
|
338
|
+
try {
|
|
339
|
+
await mount?.dispose?.();
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
249
342
|
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 });
|
|
343
|
+
await fleet?.dispose();
|
|
259
344
|
}
|
|
260
|
-
|
|
261
|
-
|
|
345
|
+
finally {
|
|
346
|
+
if (reporter) {
|
|
347
|
+
try {
|
|
348
|
+
await reporter.report(createFactoryCloudEventV1({
|
|
349
|
+
type: 'instance.stopping',
|
|
350
|
+
attributes: { component: 'cli', operation: 'stop' },
|
|
351
|
+
}));
|
|
352
|
+
await reporter.report(createFactoryCloudEventV1({
|
|
353
|
+
type: 'instance.stopped',
|
|
354
|
+
attributes: { component: 'cli', operation: 'stop' },
|
|
355
|
+
}));
|
|
356
|
+
await reporter.close?.({ deadlineMs: 2_000 });
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
err.write('[factory] warning: Cloud progress reporter failed during shutdown\n');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
262
362
|
}
|
|
263
363
|
}
|
|
264
364
|
}
|
|
@@ -279,8 +379,21 @@ export function parseFleetCommand(args) {
|
|
|
279
379
|
if (verb === 'featuremap') {
|
|
280
380
|
return parseFeatureMapCommand(rest);
|
|
281
381
|
}
|
|
382
|
+
if (verb === 'intake') {
|
|
383
|
+
return parseIntakeCommand(rest);
|
|
384
|
+
}
|
|
282
385
|
throw new Error(`Unknown factory command: ${verb}`);
|
|
283
386
|
}
|
|
387
|
+
function parseIntakeCommand(args) {
|
|
388
|
+
const [source, manifestPath, ...rest] = args;
|
|
389
|
+
if (source !== 'notion')
|
|
390
|
+
throw new Error('factory intake currently requires the notion source');
|
|
391
|
+
if (!manifestPath)
|
|
392
|
+
throw new Error('factory intake notion requires a manifest path');
|
|
393
|
+
if (rest.length > 0)
|
|
394
|
+
throw new Error(`Unexpected factory intake argument: ${rest[0]}`);
|
|
395
|
+
return { kind: 'notion-intake', manifestPath };
|
|
396
|
+
}
|
|
284
397
|
function parseFeatureMapCommand(args) {
|
|
285
398
|
const [action, ...flags] = args;
|
|
286
399
|
if (action !== 'check') {
|
|
@@ -377,17 +490,20 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
377
490
|
const debugMountRefreshes = (deps.env ?? process.env).FACTORY_LOG_LEVEL?.toLowerCase() === 'debug';
|
|
378
491
|
if (command.kind === 'factory') {
|
|
379
492
|
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
493
|
const waiter = createStopSignalWaiter();
|
|
390
494
|
let stoppedBySignal = false;
|
|
495
|
+
const logger = streamLogger(mountStderr);
|
|
496
|
+
const installedVersion = await safeFactoryVersionInfo(deps.versionInfo, false);
|
|
497
|
+
logger.info?.('[factory] daemon starting', installedVersion);
|
|
498
|
+
// The registry lookup is deliberately detached from the startup path.
|
|
499
|
+
// stop() only joins the bounded in-flight lookup during shutdown.
|
|
500
|
+
const versionMonitor = deps.versionInfo || !hasInjectedFactoryRuntime(deps)
|
|
501
|
+
? startFactoryVersionMonitor({
|
|
502
|
+
versionInfo: deps.versionInfo,
|
|
503
|
+
intervalMs: deps.versionCheckIntervalMs,
|
|
504
|
+
logger,
|
|
505
|
+
})
|
|
506
|
+
: { stop: async () => { } };
|
|
391
507
|
const flushAndResolve = async (code) => {
|
|
392
508
|
try {
|
|
393
509
|
await (deps.flushDaemonOutput ?? flushProcessOutput)();
|
|
@@ -396,6 +512,27 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
396
512
|
waiter.resolve(code);
|
|
397
513
|
}
|
|
398
514
|
};
|
|
515
|
+
// Once a workspace mirror is known, retain background stale-mount
|
|
516
|
+
// supervision so durable recovery is not serialized behind a refresh.
|
|
517
|
+
// If there is no registered root yet, however, wait for the single
|
|
518
|
+
// mount/admission fallback before Factory can dispatch: agents must not
|
|
519
|
+
// receive a provisional checkout-local `.integrations` path.
|
|
520
|
+
//
|
|
521
|
+
// A MountAuthScopeError is the exception: it is terminal (the cloud
|
|
522
|
+
// session lacks the filesystem scope the mount needs), so limping on
|
|
523
|
+
// would only spawn agents against a read-denied mirror. Fail fast with the
|
|
524
|
+
// remediation and resolve the command with a non-zero code.
|
|
525
|
+
const warmMount = () => warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
|
|
526
|
+
const handleWarmMountError = (error) => {
|
|
527
|
+
if (error instanceof MountAuthScopeError) {
|
|
528
|
+
mountStderr.write(`${error.message}\n`);
|
|
529
|
+
mountStderr.write('[factory] aborting startup: local mount cannot obtain its filesystem scopes.\n');
|
|
530
|
+
void flushAndResolve(1);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
534
|
+
mountStderr.write(`[factory] warning: background relayfile mount warmup failed: ${message}\n`);
|
|
535
|
+
};
|
|
399
536
|
const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
|
|
400
537
|
exit: (code) => {
|
|
401
538
|
stoppedBySignal = true;
|
|
@@ -407,11 +544,34 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
407
544
|
processLike: deps.stopSignalProcessLike,
|
|
408
545
|
});
|
|
409
546
|
try {
|
|
547
|
+
if (mount.getLocalMountRoot?.() === undefined) {
|
|
548
|
+
try {
|
|
549
|
+
const result = await warmMount();
|
|
550
|
+
if (!result.mounted) {
|
|
551
|
+
mountStderr.write('[factory] aborting startup: Relayfile workspace mirror could not be resolved.\n');
|
|
552
|
+
if (stoppedBySignal)
|
|
553
|
+
return await waiter.promise;
|
|
554
|
+
return 1;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
handleWarmMountError(error);
|
|
559
|
+
if (stoppedBySignal)
|
|
560
|
+
return await waiter.promise;
|
|
561
|
+
return 1;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
void warmMount().catch(handleWarmMountError);
|
|
566
|
+
}
|
|
567
|
+
if (stoppedBySignal)
|
|
568
|
+
return await waiter.promise;
|
|
410
569
|
await factory.start({ mode: command.mode });
|
|
411
570
|
const code = await (deps.waitForStopSignal?.() ?? waiter.promise);
|
|
412
571
|
return typeof code === 'number' ? code : 0;
|
|
413
572
|
}
|
|
414
573
|
finally {
|
|
574
|
+
await versionMonitor.stop();
|
|
415
575
|
removeSignalHandlers();
|
|
416
576
|
if (!stoppedBySignal) {
|
|
417
577
|
await factory.stop();
|
|
@@ -419,12 +579,13 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
419
579
|
}
|
|
420
580
|
}
|
|
421
581
|
if (command.action === 'run-once') {
|
|
422
|
-
await
|
|
582
|
+
await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
|
|
423
583
|
writeJson(out, await factory.runOnce({ dryRun: globals.dryRun }));
|
|
424
584
|
return 0;
|
|
425
585
|
}
|
|
426
586
|
if (command.action === 'status') {
|
|
427
|
-
|
|
587
|
+
const versionInfo = await safeFactoryVersionInfo(deps.versionInfo, Boolean(deps.versionInfo) || !hasInjectedFactoryRuntime(deps));
|
|
588
|
+
writeJson(out, await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs, versionInfo));
|
|
428
589
|
return 0;
|
|
429
590
|
}
|
|
430
591
|
if (command.action === 'loop-status') {
|
|
@@ -441,13 +602,16 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
441
602
|
writeJson(out, { killed: heartbeat.pid, signal: 'SIGTERM' });
|
|
442
603
|
return 0;
|
|
443
604
|
}
|
|
444
|
-
await
|
|
605
|
+
await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
|
|
445
606
|
const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
|
|
446
607
|
processLike: deps.stopSignalProcessLike,
|
|
447
608
|
});
|
|
448
609
|
try {
|
|
449
610
|
const reports = await factory.runLoop({ dryRun: globals.dryRun });
|
|
450
|
-
writeJson(out, {
|
|
611
|
+
writeJson(out, {
|
|
612
|
+
reports,
|
|
613
|
+
status: await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs),
|
|
614
|
+
});
|
|
451
615
|
}
|
|
452
616
|
finally {
|
|
453
617
|
removeSignalHandlers();
|
|
@@ -461,8 +625,11 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
461
625
|
writeJson(out, result);
|
|
462
626
|
return result.ok ? 0 : 1;
|
|
463
627
|
}
|
|
464
|
-
const
|
|
465
|
-
const decision =
|
|
628
|
+
const resolved = await resolveIssueArg(mount, command.issue, config, async () => issueProjectionStatus(factory, mount, config));
|
|
629
|
+
const decision = {
|
|
630
|
+
...await factory.triageIssue(resolved.issue),
|
|
631
|
+
issueResolution: resolved.resolution,
|
|
632
|
+
};
|
|
466
633
|
if (command.kind === 'factory-triage') {
|
|
467
634
|
writeJson(out, decision);
|
|
468
635
|
return 0;
|
|
@@ -485,23 +652,18 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
|
|
|
485
652
|
writeJson(out, await factory.dispatch(decision, { dryRun: globals.dryRun }));
|
|
486
653
|
return 0;
|
|
487
654
|
}
|
|
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);
|
|
655
|
+
async function warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug') {
|
|
656
|
+
const result = await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr);
|
|
657
|
+
writeMountRefreshSummary(result.refreshed ? [result.refreshed] : [], stderr, debug);
|
|
658
|
+
stderr.write(`[factory] Relayfile workspace mirror preflight: mounted=${result.mounted ? 1 : 0} ` +
|
|
659
|
+
`failed=${result.mounted ? 0 : 1} routedRepos=${new Set(Object.values(config.repos.byLabel)).size}\n`);
|
|
660
|
+
return result;
|
|
495
661
|
}
|
|
496
662
|
async function runStandaloneBabysitCommand(command, mount, fleet, config, globals, out, deps, workspaceId, acceptableMountIds) {
|
|
497
663
|
const repo = resolveStandaloneBabysitRepo(command.repo, config);
|
|
498
664
|
const clonePath = standaloneBabysitClonePath(repo, config);
|
|
499
665
|
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
|
-
}
|
|
666
|
+
await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, deps.stderr);
|
|
505
667
|
const pr = await readStandalonePullRequest(mount, { repo, prNumber: command.prNumber, url: command.url }, deps.babysitPrGhRunner ?? defaultGhRunner);
|
|
506
668
|
const state = pr.state?.trim().toUpperCase();
|
|
507
669
|
if (pr.merged || state === 'MERGED') {
|
|
@@ -561,7 +723,7 @@ async function runStandaloneBabysitCommand(command, mount, fleet, config, global
|
|
|
561
723
|
maintainerCanModify: pr.maintainerCanModify,
|
|
562
724
|
},
|
|
563
725
|
standaloneBabysitter: { specSource },
|
|
564
|
-
integrationsMountRoot:
|
|
726
|
+
integrationsMountRoot: resolveIntegrationsMountRoot(mount),
|
|
565
727
|
testGuidance,
|
|
566
728
|
});
|
|
567
729
|
const receiptBase = {
|
|
@@ -597,16 +759,6 @@ async function runStandaloneBabysitCommand(command, mount, fleet, config, global
|
|
|
597
759
|
writeJson(out, { status: 'spawned', ...receiptBase, agent: spawned.name });
|
|
598
760
|
return 0;
|
|
599
761
|
}
|
|
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
762
|
function resolveStandaloneBabysitRepo(repo, config) {
|
|
611
763
|
const configured = repo ?? config.repos.default;
|
|
612
764
|
if (!configured) {
|
|
@@ -636,50 +788,157 @@ function standaloneBabysitClonePath(repo, config) {
|
|
|
636
788
|
return undefined;
|
|
637
789
|
}
|
|
638
790
|
/**
|
|
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.).
|
|
791
|
+
* Ensures exactly one Relayfile mirror for the workspace. Agents receive this
|
|
792
|
+
* absolute path in their tasks, so routing more repositories never asks
|
|
793
|
+
* Relayfile to re-home the mirror into each checkout.
|
|
644
794
|
*/
|
|
645
|
-
async function
|
|
795
|
+
async function ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr = process.stderr) {
|
|
646
796
|
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');
|
|
797
|
+
const localDir = mount.getLocalMountRoot?.();
|
|
798
|
+
return ensureMountPath(mountFn, workspaceId, localDir ? dirname(localDir) : process.cwd(), mountOpts, stderr, localDir);
|
|
799
|
+
}
|
|
800
|
+
async function ensureMountPath(mountFn, workspaceId, path, mountOpts, stderr, localDir = join(resolve(path), '.integrations')) {
|
|
801
|
+
const statePath = join(localDir, '.relay', 'state.json');
|
|
668
802
|
const staleBefore = checkMountStaleness(statePath, workspaceId, mountOpts.acceptableWorkspaceIds);
|
|
669
803
|
try {
|
|
670
|
-
await mountFn(workspaceId,
|
|
804
|
+
await mountFn(workspaceId, resolve(path), {
|
|
671
805
|
...mountOpts,
|
|
672
806
|
...(staleBefore.stale ? { suppressStaleRefreshLogs: true } : {}),
|
|
673
807
|
});
|
|
674
808
|
if (staleBefore.stale && !checkMountStaleness(statePath, workspaceId, mountOpts.acceptableWorkspaceIds).stale) {
|
|
675
|
-
return { path:
|
|
809
|
+
return { mounted: true, refreshed: { path: localDir, reason: staleBefore.reason } };
|
|
676
810
|
}
|
|
811
|
+
return { mounted: true };
|
|
677
812
|
}
|
|
678
813
|
catch (error) {
|
|
814
|
+
// A scope shortfall is terminal and identical across every clone path;
|
|
815
|
+
// propagate it so startup fails fast with one remediation instead of
|
|
816
|
+
// logging the same unfixable warning per path.
|
|
817
|
+
if (error instanceof MountAuthScopeError)
|
|
818
|
+
throw error;
|
|
679
819
|
const message = error instanceof Error ? error.message : String(error);
|
|
680
|
-
stderr.write(`[factory] warning: could not start
|
|
820
|
+
stderr.write(`[factory] warning: could not start Relayfile workspace mirror at ${localDir}: ${message}\n`);
|
|
681
821
|
}
|
|
682
|
-
return
|
|
822
|
+
return { mounted: false };
|
|
823
|
+
}
|
|
824
|
+
function resolveIntegrationsMountRoot(mount) {
|
|
825
|
+
return mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
|
|
826
|
+
}
|
|
827
|
+
async function factoryStatusWithMountHealth(factory, mount, heartbeatPath, registryPath, heartbeatStaleMs, versionInfo) {
|
|
828
|
+
const status = factory.status();
|
|
829
|
+
const [heartbeat, registry] = await Promise.all([
|
|
830
|
+
readFactoryLoopHeartbeat(heartbeatPath),
|
|
831
|
+
readFactoryInFlightRegistry(registryPath),
|
|
832
|
+
]);
|
|
833
|
+
const registryDispatches = registry?.heartbeatPath && registry.heartbeatPath !== heartbeatPath
|
|
834
|
+
? []
|
|
835
|
+
: inFlightDispatchesFromRegistry(registry);
|
|
836
|
+
const observableStatus = registryDispatches.length > 0
|
|
837
|
+
? { ...status, inFlightDispatches: registryDispatches }
|
|
838
|
+
: status;
|
|
839
|
+
const liveness = checkFactoryLoopLiveness(heartbeat, { staleMs: heartbeatStaleMs });
|
|
840
|
+
const eventListener = liveness.ok
|
|
841
|
+
? heartbeat?.eventListener ?? {
|
|
842
|
+
state: 'unknown',
|
|
843
|
+
reason: 'running daemon heartbeat does not report event listener state',
|
|
844
|
+
}
|
|
845
|
+
: {
|
|
846
|
+
state: 'not-listening',
|
|
847
|
+
reason: liveness.reason,
|
|
848
|
+
};
|
|
849
|
+
const health = mount.getLocalMountHealth?.();
|
|
850
|
+
if (!health)
|
|
851
|
+
return { ...observableStatus, ...versionInfo, eventListener };
|
|
852
|
+
return {
|
|
853
|
+
...observableStatus,
|
|
854
|
+
...versionInfo,
|
|
855
|
+
eventListener,
|
|
856
|
+
localMountDegraded: health.degraded,
|
|
857
|
+
...(health.reason ? { localMountDegradedReason: health.reason } : {}),
|
|
858
|
+
...(health.localDir ? { localMountRoot: health.localDir } : {}),
|
|
859
|
+
localMountEventFeed: {
|
|
860
|
+
state: health.degraded ? 'degraded' : 'healthy',
|
|
861
|
+
livenessSignal: '.integrations/.relay/state.json',
|
|
862
|
+
...(health.reason ? { reason: health.reason } : {}),
|
|
863
|
+
...(health.localDir ? { root: health.localDir } : {}),
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
function inFlightDispatchesFromRegistry(registry) {
|
|
868
|
+
if (!registry)
|
|
869
|
+
return [];
|
|
870
|
+
const grouped = new Map();
|
|
871
|
+
const claimPriority = { verified: 0, pending: 1, degraded: 2 };
|
|
872
|
+
for (const agent of registry.agents) {
|
|
873
|
+
if (!agent.issue)
|
|
874
|
+
continue;
|
|
875
|
+
const key = `${agent.issue.key}\u0000${agent.issue.uuid}\u0000${agent.issue.path}`;
|
|
876
|
+
const claim = agent.dispatchClaim ?? {
|
|
877
|
+
state: 'pending',
|
|
878
|
+
updatedAtMs: registry.updatedAtMs,
|
|
879
|
+
};
|
|
880
|
+
const existing = grouped.get(key);
|
|
881
|
+
const entry = existing ?? {
|
|
882
|
+
issue: { ...agent.issue },
|
|
883
|
+
agents: [],
|
|
884
|
+
claim: { ...claim },
|
|
885
|
+
};
|
|
886
|
+
if (claimPriority[claim.state] > claimPriority[entry.claim.state]) {
|
|
887
|
+
entry.claim = { ...claim };
|
|
888
|
+
}
|
|
889
|
+
if (!entry.agents.some((candidate) => candidate.name === agent.name)) {
|
|
890
|
+
entry.agents.push({
|
|
891
|
+
name: agent.name,
|
|
892
|
+
...(agent.role ? { role: agent.role } : {}),
|
|
893
|
+
...(agent.sessionRef ? { sessionRef: agent.sessionRef } : {}),
|
|
894
|
+
...(agent.invocationId ? { invocationId: agent.invocationId } : {}),
|
|
895
|
+
...(agent.node ? { node: agent.node } : {}),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
grouped.set(key, entry);
|
|
899
|
+
}
|
|
900
|
+
return [...grouped.values()]
|
|
901
|
+
.map((entry) => ({
|
|
902
|
+
...entry,
|
|
903
|
+
agents: entry.agents.sort((left, right) => left.name.localeCompare(right.name)),
|
|
904
|
+
}))
|
|
905
|
+
.sort((left, right) => left.issue.key.localeCompare(right.issue.key));
|
|
906
|
+
}
|
|
907
|
+
const DEFAULT_VERSION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
|
|
908
|
+
async function safeFactoryVersionInfo(provider, includeRegistry) {
|
|
909
|
+
try {
|
|
910
|
+
return await (provider ?? readFactoryVersionInfo)({ includeRegistry });
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
return { version: 'unknown' };
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
export async function reportFactoryVersionDrift(provider, logger) {
|
|
917
|
+
const info = await safeFactoryVersionInfo(provider, true);
|
|
918
|
+
if (!isMeaningfullyBehind(info) || !info.latestVersion)
|
|
919
|
+
return;
|
|
920
|
+
logger.warn?.(`[factory] version drift detected: running ${info.version}; published latest ${info.latestVersion}`, {
|
|
921
|
+
...(info.versionsBehind !== undefined ? { versionsBehind: info.versionsBehind } : {}),
|
|
922
|
+
...(info.installedAt ? { installedAt: info.installedAt } : {}),
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
function startFactoryVersionMonitor(input) {
|
|
926
|
+
let pending;
|
|
927
|
+
const check = () => {
|
|
928
|
+
if (pending)
|
|
929
|
+
return;
|
|
930
|
+
pending = reportFactoryVersionDrift(input.versionInfo, input.logger)
|
|
931
|
+
.finally(() => { pending = undefined; });
|
|
932
|
+
};
|
|
933
|
+
check();
|
|
934
|
+
const interval = setInterval(check, input.intervalMs ?? DEFAULT_VERSION_CHECK_INTERVAL_MS);
|
|
935
|
+
interval.unref?.();
|
|
936
|
+
return {
|
|
937
|
+
stop: async () => {
|
|
938
|
+
clearInterval(interval);
|
|
939
|
+
await pending;
|
|
940
|
+
},
|
|
941
|
+
};
|
|
683
942
|
}
|
|
684
943
|
function writeMountRefreshSummary(refreshedStaleMounts, stderr, debug) {
|
|
685
944
|
if (debug) {
|
|
@@ -1018,9 +1277,28 @@ async function prepareFactoryIntegrations(command, mount, config, globals, deps,
|
|
|
1018
1277
|
if (providers.length === 0)
|
|
1019
1278
|
return;
|
|
1020
1279
|
const dryRun = globals.dryRun || command.kind === 'factory-canary';
|
|
1280
|
+
const providersToEnsure = [];
|
|
1281
|
+
for (const provider of providers) {
|
|
1282
|
+
const observation = observed.get(provider) ?? await inspectFactoryIntegration(connections, provider);
|
|
1283
|
+
observed.set(provider, observation);
|
|
1284
|
+
if (provider === 'github' &&
|
|
1285
|
+
observation.kind === 'connected-not-ready' &&
|
|
1286
|
+
mount.githubRead &&
|
|
1287
|
+
(command.kind === 'factory-triage' || command.kind === 'factory-dispatch')) {
|
|
1288
|
+
const details = [observation.state, observation.initialSyncState]
|
|
1289
|
+
.filter((value) => Boolean(value))
|
|
1290
|
+
.join(', ');
|
|
1291
|
+
err.write(`[factory] warning: GitHub projection is not ready${details ? ` (${details})` : ''}; ` +
|
|
1292
|
+
'targeted resolution remains projection-first and will use the GitHub API fallback only for a miss.\n');
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
providersToEnsure.push(provider);
|
|
1296
|
+
}
|
|
1297
|
+
if (providersToEnsure.length === 0)
|
|
1298
|
+
return;
|
|
1021
1299
|
await ensureFactoryIntegrations({
|
|
1022
1300
|
connections,
|
|
1023
|
-
providers,
|
|
1301
|
+
providers: providersToEnsure,
|
|
1024
1302
|
workspaceId,
|
|
1025
1303
|
interactive: !dryRun && (deps.isInteractive?.() ?? Boolean(process.stdin.isTTY && process.stderr.isTTY)),
|
|
1026
1304
|
dryRun,
|
|
@@ -1114,10 +1392,11 @@ async function buildMount(loaded, deps, observability = {}) {
|
|
|
1114
1392
|
if (deps.mount)
|
|
1115
1393
|
return deps.mount;
|
|
1116
1394
|
if (hasExplicitFixtureFiles(loaded))
|
|
1117
|
-
return new
|
|
1395
|
+
return new FixtureMountClient(loaded.fixtureFiles);
|
|
1118
1396
|
let mount;
|
|
1119
1397
|
mount = await (deps.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({
|
|
1120
1398
|
workspaceId: loaded.config.workspaceId,
|
|
1399
|
+
localMountRoot: loaded.config.localMountRoot,
|
|
1121
1400
|
logger: observability.logger,
|
|
1122
1401
|
onLocalMountHealth: observability.onLocalMountHealth,
|
|
1123
1402
|
isAllowedDraft: (path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, loaded.config),
|
|
@@ -1125,6 +1404,21 @@ async function buildMount(loaded, deps, observability = {}) {
|
|
|
1125
1404
|
return mount;
|
|
1126
1405
|
}
|
|
1127
1406
|
const hasExplicitFixtureFiles = (loaded) => loaded?.fixtureFiles !== undefined;
|
|
1407
|
+
/** Hermetic fixture runs have no server event feed, so expose the in-memory
|
|
1408
|
+
* tree shape as their change watermark. The discovery tree cache only
|
|
1409
|
+
* remembers which paths exist under a prefix, not their content, so the
|
|
1410
|
+
* watermark must track path membership only. Hashing full file entries
|
|
1411
|
+
* (as an earlier version of this did) also hashes each file's `revision`,
|
|
1412
|
+
* which FakeMountClient.writeFile bumps on every write — so any factory
|
|
1413
|
+
* writeback mid-sweep (a draft, a confirmed write) would change the digest
|
|
1414
|
+
* with no fixture change-event to explain the gap, and the checkpoint
|
|
1415
|
+
* would never persist across a sweep that actually dispatched anything. */
|
|
1416
|
+
class FixtureMountClient extends FakeMountClient {
|
|
1417
|
+
async getEventHighWatermark() {
|
|
1418
|
+
const paths = [...this.files.keys()].sort();
|
|
1419
|
+
return `fixture:${createHash('sha256').update(JSON.stringify(paths)).digest('hex')}`;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1128
1422
|
async function isAllowedFactoryDraft(path, content, opts, mount, config) {
|
|
1129
1423
|
if (!opts?.guarded)
|
|
1130
1424
|
return false;
|
|
@@ -1170,19 +1464,52 @@ const scopeIssueFromDraftContent = (content) => ({
|
|
|
1170
1464
|
raw: asRecord(content),
|
|
1171
1465
|
});
|
|
1172
1466
|
async function readIssueArg(mount, issueArg, config) {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1467
|
+
return (await resolveIssueArg(mount, issueArg, config)).issue;
|
|
1468
|
+
}
|
|
1469
|
+
async function resolveIssueArg(mount, issueArg, config, projectionStatus) {
|
|
1470
|
+
const explicitPath = issueArg.startsWith('/');
|
|
1471
|
+
const path = explicitPath ? issueArg : await findIssuePath(mount, issueArg, config);
|
|
1472
|
+
if (path) {
|
|
1473
|
+
const issue = githubIssuePathParts(path)
|
|
1474
|
+
? parseGithubFactoryIssue(path, (await mount.readFile(path)).content)
|
|
1475
|
+
: await readLinearIssueWithCanonicalFallback(mount, path);
|
|
1476
|
+
return {
|
|
1477
|
+
issue,
|
|
1478
|
+
resolution: {
|
|
1479
|
+
source: 'relayfile-projection',
|
|
1480
|
+
detail: 'Resolved from the preferred Relayfile projection.',
|
|
1481
|
+
projection: { outcome: 'matched' },
|
|
1482
|
+
},
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
const selector = parseGithubIssueSelector(issueArg, config);
|
|
1486
|
+
const projection = projectionStatus
|
|
1487
|
+
? await projectionStatus()
|
|
1488
|
+
: projectionStatusFromMount(mount);
|
|
1489
|
+
const unavailableReason = githubProjectionUnavailableReason(projection);
|
|
1490
|
+
if (!unavailableReason) {
|
|
1491
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: found 0 matches in the healthy Relayfile projection; ` +
|
|
1492
|
+
'the GitHub API fallback was not used');
|
|
1176
1493
|
}
|
|
1177
|
-
|
|
1494
|
+
const fallback = await findGithubIssueThroughConnection(mount, selector, config, issueArg);
|
|
1495
|
+
if (!fallback) {
|
|
1496
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: found 0 matches in the projection and GitHub API`);
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
issue: parseGithubFactoryIssue(fallback.path, fallback.content),
|
|
1500
|
+
resolution: {
|
|
1501
|
+
source: 'github-api-fallback',
|
|
1502
|
+
repo: fallback.repo,
|
|
1503
|
+
detail: `Relayfile projection could not answer (${unavailableReason}); resolved authoritatively through the GitHub API fallback.`,
|
|
1504
|
+
projection,
|
|
1505
|
+
},
|
|
1506
|
+
};
|
|
1178
1507
|
}
|
|
1179
1508
|
async function findIssuePath(mount, key, config) {
|
|
1180
1509
|
if (config.issueSource === 'github') {
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
}
|
|
1185
|
-
const configuredRepos = configuredGithubIssueRepos(config);
|
|
1510
|
+
const selector = parseGithubIssueSelector(key, config);
|
|
1511
|
+
const number = selector.number;
|
|
1512
|
+
const configuredRepos = selector.repo ? [selector.repo] : configuredGithubIssueRepos(config);
|
|
1186
1513
|
if (configuredRepos.length === 0 && hasConfiguredGithubIssueRoutes(config)) {
|
|
1187
1514
|
throw new Error(`${githubIssueResolutionError(config, key)}: configured repository routes do not resolve to owner/repo; ` +
|
|
1188
1515
|
'set repos.default to owner/repo or map its label in repos.byLabel');
|
|
@@ -1206,7 +1533,7 @@ async function findIssuePath(mount, key, config) {
|
|
|
1206
1533
|
})
|
|
1207
1534
|
.sort((left, right) => githubIssuePathPreference(left) - githubIssuePathPreference(right) || left.localeCompare(right));
|
|
1208
1535
|
if (matches.length === 0) {
|
|
1209
|
-
|
|
1536
|
+
return undefined;
|
|
1210
1537
|
}
|
|
1211
1538
|
const matchesByRepo = new Map();
|
|
1212
1539
|
for (const path of matches) {
|
|
@@ -1232,14 +1559,106 @@ async function findIssuePath(mount, key, config) {
|
|
|
1232
1559
|
}
|
|
1233
1560
|
return matches[0];
|
|
1234
1561
|
}
|
|
1235
|
-
function
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
:
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1562
|
+
async function findGithubIssueThroughConnection(mount, selector, config, issueArg) {
|
|
1563
|
+
const github = mount.githubRead;
|
|
1564
|
+
if (!github) {
|
|
1565
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: projection cannot answer and the GitHub API fallback is unavailable`);
|
|
1566
|
+
}
|
|
1567
|
+
const repos = selector.repo ? [selector.repo] : configuredGithubIssueRepos(config);
|
|
1568
|
+
if (repos.length === 0) {
|
|
1569
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: projection cannot answer and no configured owner/repo is available for the GitHub API fallback`);
|
|
1570
|
+
}
|
|
1571
|
+
const lookups = await Promise.all(repos.map((repo) => github.getIssue(repo, selector.number)));
|
|
1572
|
+
const found = lookups.filter((lookup) => lookup.outcome === 'found');
|
|
1573
|
+
const indeterminate = lookups.some((lookup) => lookup.outcome === 'indeterminate');
|
|
1574
|
+
if (found.length === 0) {
|
|
1575
|
+
if (indeterminate) {
|
|
1576
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: the projection could not answer and the GitHub API fallback ` +
|
|
1577
|
+
'could not determine whether the issue exists (one or more configured repositories are not visible without authentication)');
|
|
1578
|
+
}
|
|
1579
|
+
return undefined;
|
|
1580
|
+
}
|
|
1581
|
+
if (!selector.repo && !config.repos.default) {
|
|
1582
|
+
if (found.length > 1) {
|
|
1583
|
+
const matchedRepos = found.map((match) => match.issue.repo).sort((left, right) => left.localeCompare(right));
|
|
1584
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: GitHub API matches multiple repositories (${matchedRepos.join(', ')}); ` +
|
|
1585
|
+
'set repos.default or pass a repo-qualified argument');
|
|
1586
|
+
}
|
|
1587
|
+
if (indeterminate) {
|
|
1588
|
+
// A single confirmed match is not the same as a unique one: at least
|
|
1589
|
+
// one other configured repository could not be checked, so a same-
|
|
1590
|
+
// numbered issue could exist there too. Refuse rather than silently
|
|
1591
|
+
// dispatch to whichever repo happened to answer.
|
|
1592
|
+
throw new Error(`${githubIssueResolutionError(config, issueArg)}: GitHub API found a match in ${found[0].issue.repo} but could not ` +
|
|
1593
|
+
'confirm it is unique because one or more other configured repositories could not be checked without authentication; ' +
|
|
1594
|
+
'set repos.default or pass a repo-qualified argument');
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return found[0].issue;
|
|
1598
|
+
}
|
|
1599
|
+
async function issueProjectionStatus(factory, mount, config) {
|
|
1600
|
+
const status = await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs);
|
|
1601
|
+
const githubConnection = mount.integrationConnections
|
|
1602
|
+
? await mount.integrationConnections.getStatus('github')
|
|
1603
|
+
: undefined;
|
|
1604
|
+
return {
|
|
1605
|
+
outcome: 'no-match',
|
|
1606
|
+
...(status.localMountDegraded !== undefined ? { localMountDegraded: status.localMountDegraded } : {}),
|
|
1607
|
+
...(status.localMountDegradedReason ? { localMountDegradedReason: status.localMountDegradedReason } : {}),
|
|
1608
|
+
...(status.eventListener ? { eventListener: status.eventListener } : {}),
|
|
1609
|
+
...(githubConnection ? { githubConnection } : {}),
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
function projectionStatusFromMount(mount) {
|
|
1613
|
+
const health = mount.getLocalMountHealth?.();
|
|
1614
|
+
return {
|
|
1615
|
+
outcome: 'no-match',
|
|
1616
|
+
...(health ? { localMountDegraded: health.degraded } : {}),
|
|
1617
|
+
...(health?.reason ? { localMountDegradedReason: health.reason } : {}),
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
function githubProjectionUnavailableReason(projection) {
|
|
1621
|
+
if (projection.githubConnection && !projection.githubConnection.ready) {
|
|
1622
|
+
const detail = [projection.githubConnection.state, projection.githubConnection.initialSyncState]
|
|
1623
|
+
.filter((value) => Boolean(value))
|
|
1624
|
+
.join(', ');
|
|
1625
|
+
return `GitHub projection connection is not ready${detail ? ` (${detail})` : ''}`;
|
|
1626
|
+
}
|
|
1627
|
+
if (projection.localMountDegraded) {
|
|
1628
|
+
return projection.localMountDegradedReason ?? 'local mount is degraded';
|
|
1629
|
+
}
|
|
1630
|
+
if (projection.eventListener && !['subscribed', 'polling'].includes(projection.eventListener.state)) {
|
|
1631
|
+
return projection.eventListener.reason ?? `event listener is ${projection.eventListener.state}`;
|
|
1632
|
+
}
|
|
1633
|
+
return undefined;
|
|
1634
|
+
}
|
|
1635
|
+
export function parseGithubIssueSelector(key, config) {
|
|
1636
|
+
const qualified = key.match(/^([^#]+)#([1-9]\d*)$/u);
|
|
1637
|
+
const bare = key.match(/^#?([1-9]\d*)$/u);
|
|
1638
|
+
const number = Number(qualified?.[2] ?? bare?.[1]);
|
|
1639
|
+
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
1640
|
+
throw new Error(`${githubIssueResolutionError(config, key)}: expected a positive issue number or repo-qualified reference (repo#number)`);
|
|
1641
|
+
}
|
|
1642
|
+
if (!qualified)
|
|
1643
|
+
return { number };
|
|
1644
|
+
const requested = qualified[1];
|
|
1645
|
+
const configured = allConfiguredGithubIssueRepos(config);
|
|
1646
|
+
// Resolve what the caller typed through the exact same canonicalization
|
|
1647
|
+
// every configured route already goes through (label mapping, org
|
|
1648
|
+
// prefixing, canonical-route lookup) instead of a second, ad-hoc
|
|
1649
|
+
// expansion — three review rounds each found a different normalization
|
|
1650
|
+
// gap (default-only, org-only, bare-label-vs-normalized) in a hand-rolled
|
|
1651
|
+
// comparison here. Comparison is only ever normalized against normalized.
|
|
1652
|
+
const [resolved] = resolveGithubIssueRepoCandidates(config, [requested]);
|
|
1653
|
+
const repo = resolved
|
|
1654
|
+
? configured.find((candidate) => candidate.toLowerCase() === resolved.toLowerCase())
|
|
1655
|
+
: undefined;
|
|
1656
|
+
if (!repo) {
|
|
1657
|
+
throw new Error(`${githubIssueResolutionError(config, key)}: repository ${requested} is not one of the configured Factory routes`);
|
|
1658
|
+
}
|
|
1659
|
+
return { number, repo };
|
|
1660
|
+
}
|
|
1661
|
+
function resolveGithubIssueRepoCandidates(config, candidates) {
|
|
1243
1662
|
const repos = new Map();
|
|
1244
1663
|
const routedRepos = [
|
|
1245
1664
|
...Object.values(config.repos.byLabel),
|
|
@@ -1260,6 +1679,30 @@ function configuredGithubIssueRepos(config) {
|
|
|
1260
1679
|
}
|
|
1261
1680
|
return [...repos.values()];
|
|
1262
1681
|
}
|
|
1682
|
+
/** Bare-number resolution stays default-only: when repos.default is set, a
|
|
1683
|
+
* bare issue number resolves against that single repo, not every route. */
|
|
1684
|
+
function configuredGithubIssueRepos(config) {
|
|
1685
|
+
const candidates = config.repos.default
|
|
1686
|
+
? [config.repos.default]
|
|
1687
|
+
: [
|
|
1688
|
+
...Object.values(config.repos.byLabel),
|
|
1689
|
+
...Object.values(config.repos.byProject),
|
|
1690
|
+
...config.repos.keywordRules.map((rule) => rule.repo),
|
|
1691
|
+
];
|
|
1692
|
+
return resolveGithubIssueRepoCandidates(config, candidates);
|
|
1693
|
+
}
|
|
1694
|
+
/** Qualified `repo#number` selectors must validate against every configured
|
|
1695
|
+
* route, not just repos.default — a route reachable only through byLabel,
|
|
1696
|
+
* byProject, or keywordRules is still a valid dispatch target. */
|
|
1697
|
+
function allConfiguredGithubIssueRepos(config) {
|
|
1698
|
+
const candidates = [
|
|
1699
|
+
...(config.repos.default ? [config.repos.default] : []),
|
|
1700
|
+
...Object.values(config.repos.byLabel),
|
|
1701
|
+
...Object.values(config.repos.byProject),
|
|
1702
|
+
...config.repos.keywordRules.map((rule) => rule.repo),
|
|
1703
|
+
];
|
|
1704
|
+
return resolveGithubIssueRepoCandidates(config, candidates);
|
|
1705
|
+
}
|
|
1263
1706
|
function hasConfiguredGithubIssueRoutes(config) {
|
|
1264
1707
|
return Boolean(config.repos.default ||
|
|
1265
1708
|
Object.keys(config.repos.byLabel).length > 0 ||
|
|
@@ -1449,6 +1892,7 @@ Commands:
|
|
|
1449
1892
|
babysit <PR|URL> Shepherd an existing open PR to green
|
|
1450
1893
|
close-probe <PR> Probe/close a PR for an issue
|
|
1451
1894
|
featuremap check Validate .agentworkforce/features/manifest.yaml
|
|
1895
|
+
intake notion <file> Normalize mounted Notion specs into Factory work
|
|
1452
1896
|
fleet <command> Low-level fleet commands: spawn, roster, release
|
|
1453
1897
|
|
|
1454
1898
|
Options:
|