flowviant 0.103.0 → 0.104.0
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/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +183 -2383
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +2 -2
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +17 -17
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3034
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +91 -898
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADOPTION'S DIRTY CARRY — a terminal checkout's uncommitted state copied,
|
|
3
|
+
* read-only, into the fresh worktree a tab adopts it into.
|
|
4
|
+
*
|
|
5
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). It is git plumbing with a
|
|
6
|
+
* promise of its own (the source is never written, a problem becomes one line
|
|
7
|
+
* for the agent to say), reached once, from the session lane's adopt path.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { cpSync, lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { git, gitRaw, splitNul } from './git.mjs';
|
|
13
|
+
|
|
14
|
+
export function createAdoptCarry({ sessionMetaPath }) {
|
|
15
|
+
/**
|
|
16
|
+
* Carry a terminal checkout's DIRTY state into a fresh adopt worktree. The
|
|
17
|
+
* source is strictly READ-ONLY — nothing here writes to it, because it is
|
|
18
|
+
* the human's own checkout and adoption promises to leave it exactly as the
|
|
19
|
+
* closed terminal did. Tracked changes travel as one binary patch staged
|
|
20
|
+
* through the worktree's PRIVATE git dir (invisible to status, dies with the
|
|
21
|
+
* tree); untracked files are copied one by one, skipping anything over 5MB.
|
|
22
|
+
*
|
|
23
|
+
* Returns '' or ONE bracketed line for the turn's prompt: a carry problem is
|
|
24
|
+
* the AGENT's to explain to the user, never a reason to fail the adoption —
|
|
25
|
+
* the conversation is the thing being adopted, and it resumes either way.
|
|
26
|
+
*/
|
|
27
|
+
const carryDirtyState = (srcCwd, wt, sessionId) => {
|
|
28
|
+
const problems = [];
|
|
29
|
+
try {
|
|
30
|
+
// A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
|
|
31
|
+
// text file) must round-trip byte-exact or the apply corrupts what it
|
|
32
|
+
// carries. 64MB of headroom — a dirtier tree than that fails the read
|
|
33
|
+
// here and is SAID, below, rather than half-applied.
|
|
34
|
+
const patch = execFileSync('git', ['diff', 'HEAD', '--binary'], {
|
|
35
|
+
cwd: srcCwd,
|
|
36
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
37
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
38
|
+
});
|
|
39
|
+
if (patch.length) {
|
|
40
|
+
const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch', sessionId);
|
|
41
|
+
if (!patchPath) throw new Error('no private git dir to stage the patch in');
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(patchPath, patch);
|
|
44
|
+
git(['apply', '--whitespace=nowarn', patchPath], wt);
|
|
45
|
+
} finally {
|
|
46
|
+
try {
|
|
47
|
+
rmSync(patchPath, { force: true });
|
|
48
|
+
} catch {
|
|
49
|
+
/* best-effort — the private git dir dies with the worktree anyway */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
problems.push(
|
|
55
|
+
'their uncommitted TRACKED changes did not carry over (they are still in the terminal checkout, untouched)'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const skipped = [];
|
|
60
|
+
for (const rel of splitNul(
|
|
61
|
+
gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], srcCwd)
|
|
62
|
+
)) {
|
|
63
|
+
try {
|
|
64
|
+
const from = join(srcCwd, rel);
|
|
65
|
+
// lstat, not stat: a symlink is carried as itself, and its own size
|
|
66
|
+
// is what the 5MB budget judges — never the file it points at.
|
|
67
|
+
if (lstatSync(from).size > 5 * 1024 * 1024) {
|
|
68
|
+
skipped.push(rel);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const to = join(wt, rel);
|
|
72
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
73
|
+
cpSync(from, to);
|
|
74
|
+
} catch {
|
|
75
|
+
skipped.push(rel);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (skipped.length) {
|
|
79
|
+
problems.push(
|
|
80
|
+
`${skipped.length} untracked file${skipped.length === 1 ? '' : 's'} did not carry (over 5MB or unreadable): ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ', …' : ''}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
problems.push(
|
|
85
|
+
'untracked files could not be listed in the terminal checkout, so none were carried'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return problems.length
|
|
89
|
+
? `[ADOPTION NOTE from the daemon — tell the user plainly at the start of your reply: ${problems.join('; ')}.]`
|
|
90
|
+
: '';
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return { carryDirtyState };
|
|
94
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROJECT'S OWN CHECK — the repo's declared command, run in the agent's
|
|
3
|
+
* worktree the moment its queue empties, and its answer posted as a label.
|
|
4
|
+
*
|
|
5
|
+
* Split out of workAgentReview.mjs (SOLID F039): this half changes for
|
|
6
|
+
* SECURITY reasons (what a repo-controlled command may see and reach — the
|
|
7
|
+
* artifact-directory fence, the environment without the machine credential,
|
|
8
|
+
* the process-group kill), the pre-review for MODEL-REPORTING ones. The review
|
|
9
|
+
* entry beat sequences the two and owns neither.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, lstatSync, rmSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { FLEET_URL } from './config.mjs';
|
|
15
|
+
import { git } from './git.mjs';
|
|
16
|
+
import { warn } from './ui.mjs';
|
|
17
|
+
import { ARTIFACT_DIR, artifactTypeFor } from './artifacts.mjs';
|
|
18
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
19
|
+
import { withoutMachineCredentials } from './machineEnv.mjs';
|
|
20
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
21
|
+
import { workLanesStopped } from './standDownGate.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* CLEAR THE ARTIFACTS DIRECTORY OF ANYTHING THAT IS NOT AN ARTIFACT, BEFORE
|
|
25
|
+
* THE PROJECT'S CHECK RUNS OVER THE WORKTREE (2026-09-24).
|
|
26
|
+
*
|
|
27
|
+
* A design or research turn may write under `.flowviant/artifacts/` and
|
|
28
|
+
* nowhere else — that posture's whole safety claim is that such a card
|
|
29
|
+
* changes no repository file. But the check is the repo's own command, run
|
|
30
|
+
* unattended in the same worktree the moment the queue empties, and a test
|
|
31
|
+
* runner's DEFAULT discovery reaches into that directory: vitest collected and
|
|
32
|
+
* ran `.flowviant/artifacts/pwn.test.js` with no include override. So a card
|
|
33
|
+
* steered by text it read could plant a test and have the machine execute it,
|
|
34
|
+
* before any person looked — and the directory is git-excluded, so the file
|
|
35
|
+
* never appears in the diff a reviewer reads.
|
|
36
|
+
*
|
|
37
|
+
* What an artifact IS is already a closed list (`ARTIFACT_TYPES`), and the
|
|
38
|
+
* scan only ever shows TOP-LEVEL regular files: anything else there is
|
|
39
|
+
* nothing the product will show, so removing it before the check costs
|
|
40
|
+
* nothing a person could see. Symlinks and subdirectories go whole; a
|
|
41
|
+
* `.flowviant` that is not a real directory is left alone (that is the
|
|
42
|
+
* repository's own content, not something a turn wrote). Returns the names
|
|
43
|
+
* removed, for the log line.
|
|
44
|
+
*/
|
|
45
|
+
export function clearNonArtifacts(wt) {
|
|
46
|
+
const removed = [];
|
|
47
|
+
try {
|
|
48
|
+
const parent = lstatSync(join(wt, '.flowviant'));
|
|
49
|
+
if (parent.isSymbolicLink() || !parent.isDirectory()) return removed;
|
|
50
|
+
const dir = join(wt, ARTIFACT_DIR);
|
|
51
|
+
const st = lstatSync(dir);
|
|
52
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
53
|
+
rmSync(dir, { force: true, recursive: false });
|
|
54
|
+
removed.push(ARTIFACT_DIR);
|
|
55
|
+
return removed;
|
|
56
|
+
}
|
|
57
|
+
for (const name of readdirSync(dir)) {
|
|
58
|
+
const p = join(dir, name);
|
|
59
|
+
let e;
|
|
60
|
+
try {
|
|
61
|
+
e = lstatSync(p);
|
|
62
|
+
} catch {
|
|
63
|
+
continue; // vanished between the list and the stat
|
|
64
|
+
}
|
|
65
|
+
const keep = e.isFile() && !name.startsWith('.') && artifactTypeFor(name) !== null;
|
|
66
|
+
if (keep) continue;
|
|
67
|
+
try {
|
|
68
|
+
rmSync(p, { recursive: true, force: true });
|
|
69
|
+
removed.push(name);
|
|
70
|
+
} catch {
|
|
71
|
+
/* best-effort — reported by omission */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
/* no artifacts directory — the ordinary case */
|
|
76
|
+
}
|
|
77
|
+
return removed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* THE ENVIRONMENT THE PROJECT'S CHECK RUNS UNDER: the daemon's own, minus the
|
|
82
|
+
* machine credential — the same rule `cliEnv` (runTurn.mjs) applies to a turn,
|
|
83
|
+
* imported from its one home (machineEnv.mjs) rather than from the CLI
|
|
84
|
+
* module, so the check never depends on the CLI module's spawn helpers. Nothing a check runs needs the credential the daemon
|
|
85
|
+
* authenticates with; everything else is what the agent's own turn saw when it
|
|
86
|
+
* ran the same tests.
|
|
87
|
+
*/
|
|
88
|
+
export function checkEnv(env = process.env) {
|
|
89
|
+
return withoutMachineCredentials(env);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, groupKillChildren }) {
|
|
93
|
+
const AGENT_CHECK_DONE_URL = fleetEndpoint('agent-check-done', FLEET_URL);
|
|
94
|
+
|
|
95
|
+
// ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
|
|
96
|
+
//
|
|
97
|
+
// The check runs in the agent's own worktree the moment its queue empties, so
|
|
98
|
+
// a reviewer knows before they start reading whether they are reviewing
|
|
99
|
+
// working code. It LABELS the review row; it never blocks it.
|
|
100
|
+
//
|
|
101
|
+
// IT IS THE REPO'S COMMAND, DECLARED IN THE REPO. `.flowviant/check.json`,
|
|
102
|
+
// beside `deploy.json`, because a check travels with the code and changes
|
|
103
|
+
// with it — a setting in the app would go stale the first time somebody
|
|
104
|
+
// renamed a script. An absent file is a MEASURED answer ('none'), not a nag:
|
|
105
|
+
// plenty of projects have no single command that means "is this alright".
|
|
106
|
+
//
|
|
107
|
+
// It runs through a shell, and that is no wider than what already happens in
|
|
108
|
+
// that directory: every turn in this worktree spawns a CLI with build
|
|
109
|
+
// permissions, so a repository that can run arbitrary code during a turn can
|
|
110
|
+
// run it here too. What this is NOT is the deleted dev-run supervisor —
|
|
111
|
+
// nothing here resolves a command, guesses a stack, or starts a server.
|
|
112
|
+
const CHECK_TIMEOUT_MS = 10 * 60_000;
|
|
113
|
+
const CHECK_OUTPUT_CAP = 4000;
|
|
114
|
+
|
|
115
|
+
const readCheckCommand = () => {
|
|
116
|
+
try {
|
|
117
|
+
const raw = readFileSync(join(repoRoot, '.flowviant', 'check.json'), 'utf8');
|
|
118
|
+
const cfg = JSON.parse(raw);
|
|
119
|
+
const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
|
|
120
|
+
return cmd ? cmd.slice(0, 500) : null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null; // absent, unreadable or not JSON — all mean "no check"
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const postCheck = async (body) => {
|
|
127
|
+
// Same bounded retry as postAgentPlan — see postBestEffort. On the
|
|
128
|
+
// final failure the row simply keeps its previous answer, which is
|
|
129
|
+
// null the first time.
|
|
130
|
+
await postBestEffort(AGENT_CHECK_DONE_URL, body);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const runCheck = async (agentId, wt) => {
|
|
134
|
+
const cmd = readCheckCommand();
|
|
135
|
+
const headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || undefined;
|
|
136
|
+
if (!cmd) {
|
|
137
|
+
await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const cleared = clearNonArtifacts(wt);
|
|
141
|
+
if (cleared.length)
|
|
142
|
+
warn(
|
|
143
|
+
`agent ${agentId}: removed ${cleared.length} non-artifact file(s) from ${ARTIFACT_DIR} before the check — ${cleared.slice(0, 5).join(', ')}`
|
|
144
|
+
);
|
|
145
|
+
const out = await new Promise((resolve) => {
|
|
146
|
+
let text = '';
|
|
147
|
+
let done = false;
|
|
148
|
+
const finish = (status) => {
|
|
149
|
+
if (done) return;
|
|
150
|
+
done = true;
|
|
151
|
+
// A check the teardown killed never reports (standDownGate.mjs): the
|
|
152
|
+
// answer stays pending, as `process.exit` left it before the drain.
|
|
153
|
+
if (workLanesStopped()) return;
|
|
154
|
+
resolve({ status, text });
|
|
155
|
+
};
|
|
156
|
+
// After the teardown no check starts; this promise stays pending.
|
|
157
|
+
if (workLanesStopped()) return;
|
|
158
|
+
let child;
|
|
159
|
+
try {
|
|
160
|
+
// DETACHED, so the child's pid is its PROCESS GROUP. A check is almost
|
|
161
|
+
// always a shell that spawns the real runner, and signalling the shell
|
|
162
|
+
// alone leaves the runner holding the worktree — and this place's
|
|
163
|
+
// WRITER lock — for as long as it likes.
|
|
164
|
+
//
|
|
165
|
+
// AND WITHOUT THE MACHINE CREDENTIAL. The check is the repo's command
|
|
166
|
+
// run with nobody watching, and it inherited `FLOWVIANT_MACHINE_TOKEN`
|
|
167
|
+
// — so anything the check executes (a test a turn wrote, a
|
|
168
|
+
// dependency's postinstall) could read the credential the whole
|
|
169
|
+
// machine authenticates with. It is `checkEnv()`, the environment the
|
|
170
|
+
// agent's own turn ran under (`cliEnv`'s rule), NOT `childEnv`'s
|
|
171
|
+
// allowlist: the agent runs these same tests in its turn, and a check
|
|
172
|
+
// stripped of `JAVA_HOME`, `DATABASE_URL` or the rest of the operator's
|
|
173
|
+
// shell would FAIL where the agent's own run passed — a "Check failed"
|
|
174
|
+
// the product manufactured and then relayed as the project's verdict.
|
|
175
|
+
// The planted-file path is closed by `clearNonArtifacts` above.
|
|
176
|
+
child = spawn(cmd, {
|
|
177
|
+
cwd: wt,
|
|
178
|
+
shell: true,
|
|
179
|
+
detached: true,
|
|
180
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
181
|
+
env: checkEnv(),
|
|
182
|
+
});
|
|
183
|
+
/**
|
|
184
|
+
* …AND IT IS TRACKED, so a stop or a takeover takes it with them.
|
|
185
|
+
*
|
|
186
|
+
* A check is a full test or build run in the agent's worktree, and it
|
|
187
|
+
* was the one long-lived child the daemon spawned without telling its
|
|
188
|
+
* own teardown about it. `shutdownWork` signalled the CLI children and
|
|
189
|
+
* left this one — so restarting the daemon, or a same-repo takeover,
|
|
190
|
+
* orphaned a running test suite inside a worktree the sweep may then
|
|
191
|
+
* try to remove. The ten-minute timer would eventually kill it, but by
|
|
192
|
+
* then it belongs to no daemon and nothing on any surface names it.
|
|
193
|
+
*
|
|
194
|
+
* Registered for a GROUP kill: with `shell: true` the child is
|
|
195
|
+
* `/bin/sh`, and signalling it leaves the runner it started behind —
|
|
196
|
+
* which is the process actually holding the worktree. See
|
|
197
|
+
* `groupKillChildren` for why this one is exempt from the
|
|
198
|
+
* never-signal-the-group rule.
|
|
199
|
+
*/
|
|
200
|
+
workChildren.set(child, agentId);
|
|
201
|
+
groupKillChildren.add(child);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// TEXT BEFORE FINISH: `finish` captures `text` by value into the
|
|
204
|
+
// resolved object, so assigning afterwards threw the spawn error away
|
|
205
|
+
// and the surface showed an empty failure.
|
|
206
|
+
text = String(e?.message || e);
|
|
207
|
+
finish('failed');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The TAIL, not the head: a failing check says why at the end. And
|
|
212
|
+
* SCRUBBED BEFORE IT IS CUT, which is the order that matters.
|
|
213
|
+
*
|
|
214
|
+
* It used to slice first: `text = (text + buf).slice(-CAP)`, with a
|
|
215
|
+
* single `envScrub` at the very end. `envScrub` replaces EXACT full
|
|
216
|
+
* values, so any credential straddling either boundary — the rolling
|
|
217
|
+
* window's, or a chunk's — was already cut in half by the time it was
|
|
218
|
+
* looked at, matched nothing, and the surviving tail was written to
|
|
219
|
+
* `agent.checkOutput` and shown to every member of the project. A failing
|
|
220
|
+
* integration test that dumps its environment is an ordinary way to reach
|
|
221
|
+
* that, and the partial is enough where the prefix of the key is a
|
|
222
|
+
* publicly known constant.
|
|
223
|
+
*
|
|
224
|
+
* Scrubbing on every chunk fixes both straddles at once: the accumulated
|
|
225
|
+
* text always holds the previous kept tail plus the whole new chunk, so a
|
|
226
|
+
* value split across chunks is whole here, and a value near the window
|
|
227
|
+
* edge is redacted before anything is discarded. Bounded work — the
|
|
228
|
+
* string is never longer than the cap plus one chunk.
|
|
229
|
+
*
|
|
230
|
+
* The one case it cannot cover is a secret LONGER than the cap itself,
|
|
231
|
+
* which can never sit in the window whole. The final scrub below stays as
|
|
232
|
+
* the second pass over what actually ships.
|
|
233
|
+
*/
|
|
234
|
+
const keep = (buf) => {
|
|
235
|
+
text = envScrub(text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
|
|
236
|
+
};
|
|
237
|
+
child.stdout?.on('data', keep);
|
|
238
|
+
child.stderr?.on('data', keep);
|
|
239
|
+
const timer = setTimeout(() => {
|
|
240
|
+
try {
|
|
241
|
+
// The GROUP, not the child: killing the shell leaves whatever it
|
|
242
|
+
// started running, which is the thing actually taking ten minutes.
|
|
243
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
244
|
+
} catch {
|
|
245
|
+
try {
|
|
246
|
+
child.kill('SIGKILL');
|
|
247
|
+
} catch {
|
|
248
|
+
/* already gone */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
text += '\n[flowviant] the check ran past ten minutes and was stopped';
|
|
252
|
+
// RESOLVE HERE TOO. Waiting for 'close' after a kill is the shape that
|
|
253
|
+
// hangs: if the group is already gone the event never arrives, and this
|
|
254
|
+
// promise holds the place's writer lock forever.
|
|
255
|
+
finish('failed');
|
|
256
|
+
}, CHECK_TIMEOUT_MS);
|
|
257
|
+
child.on('error', (e) => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
workChildren.delete(child);
|
|
260
|
+
text += String(e?.message || e);
|
|
261
|
+
finish('failed');
|
|
262
|
+
});
|
|
263
|
+
child.on('close', (code) => {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
workChildren.delete(child);
|
|
266
|
+
finish(code === 0 ? 'passed' : 'failed');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
await postCheck({
|
|
270
|
+
agentId,
|
|
271
|
+
status: out.status,
|
|
272
|
+
output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
|
|
273
|
+
...(headSha ? { headSha } : {}),
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return { runCheck };
|
|
278
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Agent merge claims, pull-request approval, and branch cleanup. */
|
|
2
|
-
import { ghFirstLine, PR_URL_RE } from './
|
|
2
|
+
import { ensureOpenPr, ghFirstLine, ghReady, mergeAndVerifyTip, PR_URL_RE } from './prWorkflow.mjs';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { join } from 'node:path';
|
|
@@ -7,8 +7,10 @@ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mj
|
|
|
7
7
|
import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
8
8
|
import { isPublishRef, publishPushArgs, publishDeleteArgs, publishErrorText } from './agentPublish.mjs';
|
|
9
9
|
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
10
|
+
import { gitMergeIn } from './gitMergeIdentity.mjs';
|
|
10
11
|
import { warn } from './ui.mjs';
|
|
11
|
-
import { scrub as envScrub } from './
|
|
12
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
13
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
12
14
|
|
|
13
15
|
export function createWorkAgentMerges({
|
|
14
16
|
repoRoot,
|
|
@@ -19,12 +21,11 @@ export function createWorkAgentMerges({
|
|
|
19
21
|
runReviewEntry,
|
|
20
22
|
onRepoChanged,
|
|
21
23
|
landed,
|
|
22
|
-
|
|
24
|
+
agentRemoteAt,
|
|
25
|
+
agentPublished,
|
|
23
26
|
}) {
|
|
24
|
-
const AGENT_MERGE_CLAIM_URL =
|
|
25
|
-
const AGENT_MERGE_DONE_URL =
|
|
26
|
-
|
|
27
|
-
const { agentRemoteAt, agentPublished } = state;
|
|
27
|
+
const AGENT_MERGE_CLAIM_URL = fleetEndpoint('agent-merge-claim', FLEET_URL);
|
|
28
|
+
const AGENT_MERGE_DONE_URL = fleetEndpoint('agent-merge-done', FLEET_URL);
|
|
28
29
|
|
|
29
30
|
// ── THE MERGE ──────────────────────────────────────────────────────────────
|
|
30
31
|
//
|
|
@@ -75,31 +76,9 @@ export function createWorkAgentMerges({
|
|
|
75
76
|
}
|
|
76
77
|
};
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
* ship's merge keeps, so a bare machine does not fail the fold with
|
|
82
|
-
* "Please tell me who you are".
|
|
83
|
-
*/
|
|
84
|
-
const gitMerge = (args, cwd) => {
|
|
85
|
-
let idEnv = null;
|
|
86
|
-
try {
|
|
87
|
-
git(['config', 'user.email'], repoRoot);
|
|
88
|
-
} catch {
|
|
89
|
-
idEnv = {
|
|
90
|
-
GIT_AUTHOR_NAME: 'Flowviant',
|
|
91
|
-
GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
|
|
92
|
-
GIT_COMMITTER_NAME: 'Flowviant',
|
|
93
|
-
GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
return execFileSync('git', args, {
|
|
97
|
-
cwd,
|
|
98
|
-
encoding: 'utf8',
|
|
99
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
|
-
...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
|
|
101
|
-
});
|
|
102
|
-
};
|
|
79
|
+
// A merge COMMIT needs an identity the machine may not have; the probe and
|
|
80
|
+
// the daemon's fallback live in gitMergeIdentity.mjs.
|
|
81
|
+
const gitMerge = gitMergeIn(repoRoot);
|
|
103
82
|
|
|
104
83
|
const runAgentMerge = async (job) => {
|
|
105
84
|
const agentId = String(job.agentId);
|
|
@@ -221,34 +200,23 @@ export function createWorkAgentMerges({
|
|
|
221
200
|
* agent approval failed at the push and came back to review carrying
|
|
222
201
|
* git's refusal, forever.
|
|
223
202
|
*
|
|
224
|
-
* PUSH,
|
|
225
|
-
* session PR job
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* blinding the landed observer's trailer read. Adopt only an OPEN PR:
|
|
230
|
-
* gh's branch finder falls back to the most recent merged one, and
|
|
231
|
-
* adopting a dead PR would report success over work that never moves.
|
|
203
|
+
* PUSH, then the shared GitHub rules (`prWorkflow.mjs`, the same ones
|
|
204
|
+
* the session PR job runs): adopt only an OPEN PR into this project's
|
|
205
|
+
* base or create one with `--fill`, merge with `--merge`, and report ok
|
|
206
|
+
* only once the tip is measured on base. What is the AGENT's stays
|
|
207
|
+
* here: which ref is the head, the leased push, and every sentence.
|
|
232
208
|
*/
|
|
233
209
|
if (job.prMode) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
// hole, a hung credential helper) stops every turn on the machine,
|
|
239
|
-
// not merely this merge, and holds the lock while it does.
|
|
240
|
-
execFileSync('gh', ['auth', 'status'], {
|
|
241
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
242
|
-
timeout: 20_000,
|
|
243
|
-
});
|
|
244
|
-
} catch (e) {
|
|
210
|
+
// EVERY `gh` CALL IS TIMED OUT (prWorkflow.mjs) — this one runs
|
|
211
|
+
// INSIDE the place writer lock.
|
|
212
|
+
const ghDown = ghReady();
|
|
213
|
+
if (ghDown) {
|
|
245
214
|
await report({
|
|
246
215
|
agentId,
|
|
247
216
|
ok: false,
|
|
248
|
-
detail:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
: `this project merges through pull requests, and gh is not signed in here: ${ghFirstLine(e)}`,
|
|
217
|
+
detail: ghDown.missing
|
|
218
|
+
? 'this project merges through pull requests, and the GitHub CLI (gh) is not installed on this machine'
|
|
219
|
+
: `this project merges through pull requests, and gh is not signed in here: ${ghDown.error}`,
|
|
252
220
|
});
|
|
253
221
|
return;
|
|
254
222
|
}
|
|
@@ -306,102 +274,46 @@ export function createWorkAgentMerges({
|
|
|
306
274
|
await report({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
|
|
307
275
|
return;
|
|
308
276
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
);
|
|
318
|
-
if (j?.state === 'OPEN' && typeof j?.url === 'string') {
|
|
319
|
-
// Adoptable only when it points at the project's own base. A PR
|
|
320
|
-
// somebody opened by hand against another branch would otherwise
|
|
321
|
-
// be merged INTO that branch, and the ok settle would claim work
|
|
322
|
-
// reached base that landed somewhere else entirely. Refused only
|
|
323
|
-
// on a MEASURED mismatch — an absent field adopts as before.
|
|
324
|
-
if (typeof j?.baseRefName === 'string' && j.baseRefName !== prBase) {
|
|
325
|
-
await report({
|
|
326
|
-
agentId,
|
|
327
|
-
ok: false,
|
|
328
|
-
detail: `the open pull request for ${head} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
|
|
329
|
-
});
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
prUrl = j.url.trim();
|
|
333
|
-
}
|
|
334
|
-
} catch {
|
|
335
|
-
/* no PR for this branch at all — created below */
|
|
277
|
+
const pr = ensureOpenPr(head, prBase, { cwd: repoRoot });
|
|
278
|
+
if (pr.wrongBase) {
|
|
279
|
+
await report({
|
|
280
|
+
agentId,
|
|
281
|
+
ok: false,
|
|
282
|
+
detail: `the open pull request for ${head} targets ${pr.wrongBase}, not ${prBase} — retarget or close it, then approve again`,
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
336
285
|
}
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
'gh',
|
|
341
|
-
// baseBranchName, not baseRef: gh 422s on a remote-tracking name.
|
|
342
|
-
['pr', 'create', '--head', head, '--base', prBase, '--fill'],
|
|
343
|
-
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
|
344
|
-
)
|
|
345
|
-
.toString()
|
|
346
|
-
.trim();
|
|
347
|
-
prUrl = out.split('\n').filter(Boolean).pop() ?? null;
|
|
348
|
-
} catch (e) {
|
|
349
|
-
await report({ agentId, ok: false, detail: ghFirstLine(e) });
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
286
|
+
if (pr.error) {
|
|
287
|
+
await report({ agentId, ok: false, detail: pr.error });
|
|
288
|
+
return;
|
|
352
289
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
290
|
+
const prUrl = pr.url;
|
|
291
|
+
const linked = prUrl && PR_URL_RE.test(prUrl);
|
|
292
|
+
const merged = await mergeAndVerifyTip(head, {
|
|
293
|
+
cwd: repoRoot,
|
|
294
|
+
tip,
|
|
295
|
+
baseRef,
|
|
296
|
+
repoRoot,
|
|
297
|
+
fetchOrigin: () => gitNet(['fetch', 'origin', '--quiet'], 60_000),
|
|
298
|
+
});
|
|
299
|
+
if (merged.error) {
|
|
300
|
+
await report({
|
|
301
|
+
agentId,
|
|
302
|
+
ok: false,
|
|
303
|
+
detail: linked ? `${merged.error} — the pull request is at ${prUrl}` : merged.error,
|
|
358
304
|
});
|
|
359
|
-
|
|
360
|
-
const line = ghFirstLine(e);
|
|
361
|
-
// Already merged is a SUCCESS: a re-offered job, or somebody merged
|
|
362
|
-
// it in the browser. The observer closes the cards either way.
|
|
363
|
-
if (!/already merged/i.test(line)) {
|
|
364
|
-
await report({
|
|
365
|
-
agentId,
|
|
366
|
-
ok: false,
|
|
367
|
-
detail:
|
|
368
|
-
prUrl && PR_URL_RE.test(prUrl) ? `${line} — the pull request is at ${prUrl}` : line,
|
|
369
|
-
});
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
// VERIFY before reporting ok: modern gh exits 0 on an already-MERGED
|
|
374
|
-
// PR, and on a repo with a merge queue or auto-merge it exits 0 after
|
|
375
|
-
// ENQUEUEING — in both, "merged" is a claim about the future. The tip
|
|
376
|
-
// being an ancestor of base is the fact `ok` asserts, and the server
|
|
377
|
-
// closes every delivered card on this sha the moment it hears it — so
|
|
378
|
-
// measure it, with one short retry for the fetch racing GitHub's
|
|
379
|
-
// merge commit. The same guard the session PR path carries.
|
|
380
|
-
const tipOnBase = () => {
|
|
381
|
-
try {
|
|
382
|
-
git(['merge-base', '--is-ancestor', tip, baseRef()], repoRoot);
|
|
383
|
-
return true;
|
|
384
|
-
} catch {
|
|
385
|
-
return false;
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
let landedOnBase = false;
|
|
389
|
-
for (let attempt = 0; attempt < 2 && !landedOnBase; attempt++) {
|
|
390
|
-
if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
|
|
391
|
-
try {
|
|
392
|
-
gitNet(['fetch', 'origin', '--quiet'], 60_000);
|
|
393
|
-
} catch {
|
|
394
|
-
/* offline — the check below answers from what we have */
|
|
395
|
-
}
|
|
396
|
-
landedOnBase = tipOnBase();
|
|
305
|
+
return;
|
|
397
306
|
}
|
|
398
|
-
if (
|
|
307
|
+
if (merged.notOnBase) {
|
|
308
|
+
// gh said yes and base does not have the tip: a merge queue may
|
|
309
|
+
// still be running it, or an older PR merged. The server closes
|
|
310
|
+
// every delivered card on this sha the moment it hears ok.
|
|
399
311
|
await report({
|
|
400
312
|
agentId,
|
|
401
313
|
ok: false,
|
|
402
314
|
detail:
|
|
403
315
|
"GitHub accepted the merge, but this branch's tip is not on the base branch — a merge queue may still be running it, or the PR that merged was an older one. Approve again once it lands." +
|
|
404
|
-
(
|
|
316
|
+
(linked ? ` The pull request is at ${prUrl}.` : ''),
|
|
405
317
|
});
|
|
406
318
|
return;
|
|
407
319
|
}
|