flowviant 0.102.2 → 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 +184 -2396
- 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 +34 -3
- 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/shipMerge.mjs +33 -2
- 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 +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- 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 +92 -897
- 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
package/bin/lib/workShip.mjs
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
/** Session branch shipping and settlement. */
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import { git, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
6
5
|
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
6
|
+
import { gitMergeIn } from './gitMergeIdentity.mjs';
|
|
7
7
|
import { c, note, ok, warn } from './ui.mjs';
|
|
8
|
-
import { scrub as envScrub } from './
|
|
8
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
9
|
+
import { REPO_PLACE } from './workPlaces.mjs';
|
|
10
|
+
import { turnLockedByLivePid } from './turnLock.mjs';
|
|
9
11
|
|
|
10
12
|
export function createWorkShipper({
|
|
11
13
|
inPlace,
|
|
12
14
|
placeOf,
|
|
13
15
|
settleShip,
|
|
14
|
-
REPO_PLACE,
|
|
15
16
|
repoRoot,
|
|
16
17
|
baseDir,
|
|
17
18
|
gitNet,
|
|
18
19
|
baseRef,
|
|
19
20
|
placeWtFor,
|
|
20
|
-
turnLockedByLivePid,
|
|
21
21
|
sessionMetaPath,
|
|
22
|
-
|
|
22
|
+
shipping,
|
|
23
|
+
pendingShipReports,
|
|
23
24
|
}) {
|
|
24
|
-
const { shipping, pendingShipReports } = state;
|
|
25
|
-
|
|
26
25
|
// Ship — a session's branch merging to main, on the human's word.
|
|
27
26
|
//
|
|
28
27
|
// --no-ff, NEVER squash: every delivered card carries commit shas as its
|
|
@@ -154,28 +153,9 @@ export function createWorkShipper({
|
|
|
154
153
|
return false;
|
|
155
154
|
}
|
|
156
155
|
};
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
// the fold with "Please tell me who you are".
|
|
161
|
-
let idEnv = null;
|
|
162
|
-
try {
|
|
163
|
-
git(['config', 'user.email'], repoRoot);
|
|
164
|
-
} catch {
|
|
165
|
-
idEnv = {
|
|
166
|
-
GIT_AUTHOR_NAME: 'Flowviant',
|
|
167
|
-
GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
|
|
168
|
-
GIT_COMMITTER_NAME: 'Flowviant',
|
|
169
|
-
GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
const gitMerge = (args, cwd) =>
|
|
173
|
-
execFileSync('git', args, {
|
|
174
|
-
cwd,
|
|
175
|
-
encoding: 'utf8',
|
|
176
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
177
|
-
...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
|
|
178
|
-
});
|
|
156
|
+
// A merge COMMIT needs an identity the machine may not have; the
|
|
157
|
+
// probe and the daemon's fallback live in gitMergeIdentity.mjs.
|
|
158
|
+
const gitMerge = gitMergeIn(repoRoot);
|
|
179
159
|
// Receipts for a range: --no-merges, because fold commits describe
|
|
180
160
|
// plumbing, not work.
|
|
181
161
|
const logCommits = (range) =>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE TURN'S TOOL LOG — the collector behind the transcript's tool cards.
|
|
3
|
+
*
|
|
4
|
+
* Split out of work.mjs's session turn (2026-09-26, SOLID F037) as pure logic:
|
|
5
|
+
* it folds the CLI's own tool events into the capped, collapsed shape the
|
|
6
|
+
* server stores, and nothing else. A fresh log per turn; the lane reads
|
|
7
|
+
* `toolLog` for the live beat and the settle, and feeds `pushToolEvent` from
|
|
8
|
+
* the stream. Tested directly (workToolLog.test.mjs).
|
|
9
|
+
*/
|
|
10
|
+
import { toolEventOf } from './runtimeEvents.mjs';
|
|
11
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* THE TURN'S TOOL LOG — the structured relay behind the transcript's
|
|
15
|
+
* tool cards. Same source as the narrator (the CLI's own tool_use
|
|
16
|
+
* events), zero inference; scrubbed AT COLLECTION so every copy that
|
|
17
|
+
* leaves the machine — live beat and settle alike — is already clean.
|
|
18
|
+
*
|
|
19
|
+
* Shape rules, applied here because the collector is the one writer:
|
|
20
|
+
* · consecutive identical read/grep/glob/bash/task events collapse
|
|
21
|
+
* into one row with a count (n);
|
|
22
|
+
* · consecutive edits of ONE file merge, summing counts, keeping
|
|
23
|
+
* the newest preview;
|
|
24
|
+
* · the PLAN is a single event — a new TodoWrite replaces the old
|
|
25
|
+
* plan at the current position, so the log shows the latest plan
|
|
26
|
+
* where it last changed rather than five stale copies;
|
|
27
|
+
* · capped at the newest 60 NON-PLAN rows, with the shed counted
|
|
28
|
+
* call-for-call (`dropped += n`) — scrollback semantics, the
|
|
29
|
+
* same trade the transcript itself makes; the plan is exempt,
|
|
30
|
+
* because it is current state rather than scrollback.
|
|
31
|
+
*/
|
|
32
|
+
export function createToolLog(cwd) {
|
|
33
|
+
const toolLog = { ev: [], dropped: 0 };
|
|
34
|
+
const pushToolEvent = (name, input) => {
|
|
35
|
+
// envScrub rides INTO the builder, which scrubs over a bounded
|
|
36
|
+
// window BEFORE its caps — scrubbing after the cut both leaked a
|
|
37
|
+
// boundary-straddling secret's prefix and grew a capped field
|
|
38
|
+
// past the server's limits (review, 2026-09-01).
|
|
39
|
+
const e = toolEventOf(name, input, cwd, envScrub);
|
|
40
|
+
if (!e) return;
|
|
41
|
+
if (e.t === 'plan') {
|
|
42
|
+
const i = toolLog.ev.findIndex((x) => x.t === 'plan');
|
|
43
|
+
if (i >= 0) toolLog.ev.splice(i, 1);
|
|
44
|
+
toolLog.ev.push(e);
|
|
45
|
+
} else {
|
|
46
|
+
const last = toolLog.ev[toolLog.ev.length - 1];
|
|
47
|
+
const sameKey =
|
|
48
|
+
last &&
|
|
49
|
+
last.t === e.t &&
|
|
50
|
+
last.p === e.p &&
|
|
51
|
+
last.q === e.q &&
|
|
52
|
+
last.c === e.c;
|
|
53
|
+
if (sameKey && (e.t === 'edit' || e.t === 'write')) {
|
|
54
|
+
last.n = (last.n ?? 1) + 1;
|
|
55
|
+
last.a = (last.a ?? 0) + (e.a ?? 0);
|
|
56
|
+
last.d = (last.d ?? 0) + (e.d ?? 0);
|
|
57
|
+
if (e.dl) last.dl = e.dl;
|
|
58
|
+
} else if (sameKey) {
|
|
59
|
+
last.n = (last.n ?? 1) + 1;
|
|
60
|
+
} else {
|
|
61
|
+
toolLog.ev.push(e);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// The cap evicts the oldest NON-plan row: the plan is current
|
|
65
|
+
// state, not scrollback — the one card the fold keeps out — and a
|
|
66
|
+
// shed collapsed row counts its repeats, so "N steps" never
|
|
67
|
+
// understates what the cut removed.
|
|
68
|
+
while (toolLog.ev.length > 60) {
|
|
69
|
+
const i = toolLog.ev.findIndex((x) => x.t !== 'plan');
|
|
70
|
+
if (i < 0) break; // only the plan left; it stays
|
|
71
|
+
const [shed] = toolLog.ev.splice(i, 1);
|
|
72
|
+
toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return { toolLog, pushToolEvent };
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.104.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"node": ">=20"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "node --test bin/lib/*.test.mjs"
|
|
19
|
+
"test": "node --test bin/lib/*.test.mjs",
|
|
20
|
+
"prepublishOnly": "bun scripts/check-app-parity.mjs"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"libsodium-wrappers-sumo": "^0.8.4",
|
package/bin/lib/patch.mjs
DELETED
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Patch placement — landing a small change in the OWNER's own checkout.
|
|
3
|
-
*
|
|
4
|
-
* The motivating case: a teammate wants the login button to read "Sign in"
|
|
5
|
-
* while you are mid-work on auth. A clone → branch → push → PR → review → merge
|
|
6
|
-
* → pull cycle for a nine-character diff is absurd, and it blocks THEM on YOU.
|
|
7
|
-
*
|
|
8
|
-
* The naive version of this — let their agent write into your working directory
|
|
9
|
-
* — is the one thing we will not do. Two writers in one directory produce silent
|
|
10
|
-
* lost edits: the agent reads a file, thinks for forty seconds, and writes it
|
|
11
|
-
* back over the change you made in between. No conflict marker, nothing to
|
|
12
|
-
* resolve, and you find out three tasks later.
|
|
13
|
-
*
|
|
14
|
-
* So the agent still works in its OWN worktree, branched off your current
|
|
15
|
-
* branch, and the daemon carries the result across with a cherry-pick:
|
|
16
|
-
*
|
|
17
|
-
* 1. the agent commits in its worktree (no push, no PR)
|
|
18
|
-
* 2. we check the files it touched against YOUR uncommitted edits
|
|
19
|
-
* 3. only if they are disjoint do we cherry-pick into your checkout
|
|
20
|
-
*
|
|
21
|
-
* Step 2 is the whole safety argument. A collision is reported as a blocker for
|
|
22
|
-
* a human to sort out — never resolved by guessing, and never applied anyway.
|
|
23
|
-
*
|
|
24
|
-
* One patch at a time per daemon: `withPatchLock` serialises every apply in this
|
|
25
|
-
* process, so two agents can't race to write your tree. (Two daemons on one repo
|
|
26
|
-
* would still race — nothing in the daemon guards that today.)
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { git, gitRaw, splitNul, isValidSha } from './git.mjs';
|
|
30
|
-
|
|
31
|
-
/** Serialises applies within this process. */
|
|
32
|
-
let patchChain = Promise.resolve();
|
|
33
|
-
|
|
34
|
-
export function withPatchLock(fn) {
|
|
35
|
-
const run = patchChain.then(fn, fn);
|
|
36
|
-
// Keep the chain alive regardless of outcome, but don't swallow the result.
|
|
37
|
-
patchChain = run.then(
|
|
38
|
-
() => {},
|
|
39
|
-
() => {}
|
|
40
|
-
);
|
|
41
|
-
return run;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** The branch the human is actually on, or null in a detached head. */
|
|
45
|
-
export function ownerCurrentBranch(repoRoot) {
|
|
46
|
-
try {
|
|
47
|
-
const name = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot);
|
|
48
|
-
return name && name !== 'HEAD' ? name : null;
|
|
49
|
-
} catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Repo-relative paths with uncommitted changes in the owner's checkout.
|
|
56
|
-
*
|
|
57
|
-
* `-z` on purpose. The line-based form quotes any path that isn't plain ASCII
|
|
58
|
-
* ("src/caf\303\251.ts") while `git diff --name-only` renders the same file
|
|
59
|
-
* differently — so the collision check, which is the entire safety argument for
|
|
60
|
-
* patch placement, quietly stopped matching for anyone with an accent in a
|
|
61
|
-
* filename and let the cherry-pick land on top of their edits. NUL-separated
|
|
62
|
-
* output is verbatim on both sides.
|
|
63
|
-
*/
|
|
64
|
-
export function dirtyPaths(repoRoot) {
|
|
65
|
-
let out = '';
|
|
66
|
-
try {
|
|
67
|
-
out = gitRaw(['status', '--porcelain', '-z'], repoRoot);
|
|
68
|
-
} catch {
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
// Porcelain -z is "XY path\0", and a RENAME is "R new\0old\0" — the extra
|
|
72
|
-
// entry is the source, which we skip: the destination is what gets clobbered.
|
|
73
|
-
const entries = splitNul(out);
|
|
74
|
-
const paths = [];
|
|
75
|
-
for (let i = 0; i < entries.length; i++) {
|
|
76
|
-
const entry = entries[i];
|
|
77
|
-
// "XY " — two status columns and a space — then the path, verbatim.
|
|
78
|
-
if (entry.length < 4) continue;
|
|
79
|
-
const code = entry.slice(0, 2);
|
|
80
|
-
paths.push(entry.slice(3));
|
|
81
|
-
if (code.includes('R') || code.includes('C')) i += 1; // consume the source
|
|
82
|
-
}
|
|
83
|
-
return paths;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Commits the agent made on top of `base`, oldest first. */
|
|
87
|
-
export function commitsSince(cwd, base) {
|
|
88
|
-
try {
|
|
89
|
-
const out = git(['rev-list', '--reverse', `${base}..HEAD`], cwd);
|
|
90
|
-
return out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
91
|
-
} catch {
|
|
92
|
-
return [];
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Files those commits touch. `-z` to match dirtyPaths byte for byte. */
|
|
97
|
-
export function filesChanged(cwd, base) {
|
|
98
|
-
try {
|
|
99
|
-
return splitNul(gitRaw(['diff', '--name-only', '-z', `${base}..HEAD`], cwd));
|
|
100
|
-
} catch {
|
|
101
|
-
return [];
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// A patch never reaches GitHub, so the server has no provider to read a diff
|
|
106
|
-
// back from — these commits exist only here. Without carrying it across, the
|
|
107
|
-
// delivery card asks the human to Keep or Revert on the agent's word alone,
|
|
108
|
-
// which is the review we skipped the PR to avoid needing.
|
|
109
|
-
//
|
|
110
|
-
// Capped because it crosses the wire and lands in a row read on every card
|
|
111
|
-
// render: 40 files, 24KB of hunks each. Truncation is stated in the patch text
|
|
112
|
-
// rather than silently trimmed — "no diff shown" is honest, a diff that lies
|
|
113
|
-
// about its own extent is not.
|
|
114
|
-
const MAX_DIFF_FILES = 40;
|
|
115
|
-
const MAX_PATCH_BYTES = 24_000;
|
|
116
|
-
|
|
117
|
-
const DIFF_STATUS = { A: 'added', D: 'removed', M: 'modified' };
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The per-file unified diffs for the agent's commits, in the same shape the PR
|
|
121
|
-
* path returns so the delivery card renders both through one component.
|
|
122
|
-
*
|
|
123
|
-
* Rename detection is deliberately OFF (no -M): with it on, `--numstat` writes
|
|
124
|
-
* the path as `dir/{old => new}.ts`, which no longer matches anything you can
|
|
125
|
-
* pass back to `git diff -- <path>`. A rename showing up as a delete plus an add
|
|
126
|
-
* is a slightly longer diff and a correct one.
|
|
127
|
-
*/
|
|
128
|
-
export function fileDiffs(cwd, base, { range, maxFiles = MAX_DIFF_FILES } = {}) {
|
|
129
|
-
// `range` lets the per-COMMIT walk reuse this (`sha^..sha`); without it the
|
|
130
|
-
// original meaning holds — everything the agent did since `base`.
|
|
131
|
-
const rev = range ?? `${base}..HEAD`;
|
|
132
|
-
let numstat = '';
|
|
133
|
-
let names = '';
|
|
134
|
-
try {
|
|
135
|
-
numstat = git(['diff', '--numstat', '--no-renames', rev], cwd);
|
|
136
|
-
names = git(['diff', '--name-status', '--no-renames', rev], cwd);
|
|
137
|
-
} catch {
|
|
138
|
-
return [];
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const statusOf = new Map();
|
|
142
|
-
for (const line of names.split('\n')) {
|
|
143
|
-
const [letter, path] = line.split('\t');
|
|
144
|
-
if (!letter || !path) continue;
|
|
145
|
-
statusOf.set(path.trim(), letter.trim().charAt(0));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const out = [];
|
|
149
|
-
for (const line of numstat.split('\n')) {
|
|
150
|
-
if (out.length >= maxFiles) break;
|
|
151
|
-
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line.replace(/\n$/, ''));
|
|
152
|
-
if (!m) continue;
|
|
153
|
-
const path = m[3].trim();
|
|
154
|
-
// A "-" count means binary — git has no textual hunks to give us.
|
|
155
|
-
const binary = m[1] === '-' || m[2] === '-';
|
|
156
|
-
|
|
157
|
-
let patch = null;
|
|
158
|
-
if (!binary) {
|
|
159
|
-
try {
|
|
160
|
-
const full = git(['diff', rev, '--', path], cwd);
|
|
161
|
-
// Drop git's own "diff --git a/… b/…" preamble; the card shows the path.
|
|
162
|
-
const at = full.indexOf('@@');
|
|
163
|
-
const hunks = at === -1 ? full : full.slice(at);
|
|
164
|
-
patch =
|
|
165
|
-
hunks.length > MAX_PATCH_BYTES
|
|
166
|
-
? `${hunks.slice(0, MAX_PATCH_BYTES)}\n… diff truncated (${hunks.length} bytes)`
|
|
167
|
-
: hunks;
|
|
168
|
-
} catch {
|
|
169
|
-
patch = null;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
out.push({
|
|
174
|
-
path,
|
|
175
|
-
status: DIFF_STATUS[statusOf.get(path)] ?? 'modified',
|
|
176
|
-
additions: binary ? 0 : Number(m[1]),
|
|
177
|
-
deletions: binary ? 0 : Number(m[2]),
|
|
178
|
-
patch,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
return out;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Carry the agent's commits into the owner's checkout.
|
|
186
|
-
*
|
|
187
|
-
* Returns one of:
|
|
188
|
-
* { ok: true, shas, files } — applied
|
|
189
|
-
* { ok: false, reason: 'no_commits' } — the agent committed nothing
|
|
190
|
-
* { ok: false, reason: 'branch_moved' } — the owner switched branches mid-run
|
|
191
|
-
* { ok: false, reason: 'conflict', paths } — the owner is editing those files
|
|
192
|
-
* { ok: false, reason: 'apply_failed', error }
|
|
193
|
-
*
|
|
194
|
-
* Never leaves a half-applied state: a failed cherry-pick is aborted and the
|
|
195
|
-
* already-applied commits are rolled back, so the owner's tree is either fully
|
|
196
|
-
* patched or untouched.
|
|
197
|
-
*/
|
|
198
|
-
export function applyPatch({ repoRoot, cwd, basedOnBranch, commitsFrom }) {
|
|
199
|
-
// NB: uses revertPatch (declared below — hoisted) for its rollback path.
|
|
200
|
-
//
|
|
201
|
-
// Two different refs, and conflating them lost work. `basedOnBranch` is the
|
|
202
|
-
// owner's branch, used ONLY to check they haven't moved since we mirrored it;
|
|
203
|
-
// it is null when we couldn't mirror it at all (detached HEAD, a failed
|
|
204
|
-
// fetch). `commitsFrom` is where the agent's commits start, which in that case
|
|
205
|
-
// is the base ref the worktree actually got reset to. Defaulting the diff to
|
|
206
|
-
// 'HEAD' meant HEAD..HEAD — an empty range reported as "the agent committed
|
|
207
|
-
// nothing", which is a lie about work that is sitting right there.
|
|
208
|
-
const base = commitsFrom ?? basedOnBranch ?? 'HEAD';
|
|
209
|
-
const shas = commitsSince(cwd, base);
|
|
210
|
-
if (shas.length === 0) return { ok: false, reason: 'no_commits' };
|
|
211
|
-
|
|
212
|
-
// The tree we branched from must still be the tree we're landing in.
|
|
213
|
-
const current = ownerCurrentBranch(repoRoot);
|
|
214
|
-
if (basedOnBranch && current !== basedOnBranch) {
|
|
215
|
-
return { ok: false, reason: 'branch_moved', expected: basedOnBranch, actual: current };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const files = filesChanged(cwd, base);
|
|
219
|
-
const dirty = new Set(dirtyPaths(repoRoot));
|
|
220
|
-
const collisions = files.filter((f) => dirty.has(f));
|
|
221
|
-
if (collisions.length > 0) {
|
|
222
|
-
return { ok: false, reason: 'conflict', paths: collisions };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Make the agent's commits reachable from the main checkout. Worktrees of the
|
|
226
|
-
// same repo share an object store, so this needs no network and no remote.
|
|
227
|
-
const applied = [];
|
|
228
|
-
try {
|
|
229
|
-
for (const sha of shas) {
|
|
230
|
-
git(['cherry-pick', '--allow-empty', '-x', sha], repoRoot);
|
|
231
|
-
applied.push(sha);
|
|
232
|
-
}
|
|
233
|
-
} catch (e) {
|
|
234
|
-
try {
|
|
235
|
-
git(['cherry-pick', '--abort'], repoRoot);
|
|
236
|
-
} catch {
|
|
237
|
-
/* nothing in progress */
|
|
238
|
-
}
|
|
239
|
-
let rolledBack = true;
|
|
240
|
-
if (applied.length > 0) {
|
|
241
|
-
// Roll back the ones that did land — by REVERTING them, never by
|
|
242
|
-
// resetting. `git reset --hard HEAD~n` would restore every tracked file
|
|
243
|
-
// in the repo, silently destroying the owner's uncommitted work in files
|
|
244
|
-
// this patch never touched. The pre-flight guard above only clears the
|
|
245
|
-
// patch's OWN file set, so a reset here is unbounded damage for a bounded
|
|
246
|
-
// mistake. A revert touches only the files in those commits.
|
|
247
|
-
const rev = revertPatch({ repoRoot, shas: applied });
|
|
248
|
-
rolledBack = rev.ok;
|
|
249
|
-
}
|
|
250
|
-
return {
|
|
251
|
-
ok: false,
|
|
252
|
-
reason: 'apply_failed',
|
|
253
|
-
error: e?.message ?? String(e),
|
|
254
|
-
// The caller must NOT claim "your tree is untouched" when part of the
|
|
255
|
-
// patch is still sitting in the owner's history.
|
|
256
|
-
partiallyApplied: !rolledBack,
|
|
257
|
-
appliedShas: rolledBack ? [] : applied,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// The landed shas differ from the source shas (cherry-pick rewrites them);
|
|
262
|
-
// report the ones now in the owner's history, since Revert acts on those.
|
|
263
|
-
let landed = applied;
|
|
264
|
-
try {
|
|
265
|
-
const out = git(['rev-list', `-n${applied.length}`, 'HEAD'], repoRoot);
|
|
266
|
-
landed = out.split('\n').map((l) => l.trim()).filter(Boolean).reverse();
|
|
267
|
-
} catch {
|
|
268
|
-
/* keep the source shas as a best-effort record */
|
|
269
|
-
}
|
|
270
|
-
return { ok: true, shas: landed, files };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Undo a landed patch. `git revert` rather than `reset` on purpose: the owner
|
|
275
|
-
* has almost certainly committed or edited on top by now, and rewriting their
|
|
276
|
-
* history to take something back would be far worse than the patch was.
|
|
277
|
-
*/
|
|
278
|
-
export function revertPatch({ repoRoot, shas }) {
|
|
279
|
-
// These arrive over the roster and go straight into git argv. Anything that
|
|
280
|
-
// isn't a bare object id — a revision range, a leading-dash option — is
|
|
281
|
-
// refused here rather than trusted because the server said so.
|
|
282
|
-
const clean = (shas ?? []).filter(isValidSha);
|
|
283
|
-
if (clean.length === 0 || clean.length !== (shas ?? []).length) {
|
|
284
|
-
return { ok: false, error: 'refused: patch revert carried a non-sha value' };
|
|
285
|
-
}
|
|
286
|
-
const ordered = [...clean].reverse(); // newest first
|
|
287
|
-
try {
|
|
288
|
-
for (const sha of ordered) git(['revert', '--no-edit', sha], repoRoot);
|
|
289
|
-
return { ok: true };
|
|
290
|
-
} catch (e) {
|
|
291
|
-
try {
|
|
292
|
-
git(['revert', '--abort'], repoRoot);
|
|
293
|
-
} catch {
|
|
294
|
-
/* nothing in progress */
|
|
295
|
-
}
|
|
296
|
-
return { ok: false, error: e?.message ?? String(e) };
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// How many commits of a task's branch we carry across. The server used to read
|
|
302
|
-
// this from GitHub and capped at 50 for the same reason: each commit costs a
|
|
303
|
-
// diff, and a runaway branch must not fan out unbounded work or produce a row
|
|
304
|
-
// too big to read on every card render. Truncation keeps the MOST RECENT
|
|
305
|
-
// commits — the tail is what a reviewer is looking at.
|
|
306
|
-
const MAX_COMMITS = 50;
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* A task branch's commits with their real per-file diffs, in the exact shape
|
|
310
|
-
* the server's GitHub read used to return (`TaskCommit[]`).
|
|
311
|
-
*
|
|
312
|
-
* This is the function that let the GitHub App die. The server used to resolve
|
|
313
|
-
* the project's linked repo, mint an installation token, fetch
|
|
314
|
-
* `GET /pulls/{n}/commits` and then run an N+1 of `GET /commits/{sha}` for the
|
|
315
|
-
* per-file patches — up to ~52 API calls to describe work THIS process had just
|
|
316
|
-
* performed, in a checkout it is standing in. Now the daemon reports it through
|
|
317
|
-
* `report_commits` and the server reads a row.
|
|
318
|
-
*
|
|
319
|
-
* Oldest → newest, because the thread appends chronologically.
|
|
320
|
-
*/
|
|
321
|
-
export function commitHistory(cwd, base) {
|
|
322
|
-
let log = '';
|
|
323
|
-
try {
|
|
324
|
-
// %x1f/%x1e are unit/record separators: a commit subject can contain
|
|
325
|
-
// anything, tabs and pipes included, so the delimiters have to be bytes a
|
|
326
|
-
// human will never type.
|
|
327
|
-
log = git(
|
|
328
|
-
['log', '--reverse', `--max-count=${MAX_COMMITS}`, '--format=%H%x1f%s%x1f%an%x1f%aI%x1e', `${base}..HEAD`],
|
|
329
|
-
cwd,
|
|
330
|
-
);
|
|
331
|
-
} catch {
|
|
332
|
-
return [];
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const out = [];
|
|
336
|
-
for (const record of log.split('\x1e')) {
|
|
337
|
-
const line = record.trim();
|
|
338
|
-
if (!line) continue;
|
|
339
|
-
const [sha, message, authorName, committedAt] = line.split('\x1f');
|
|
340
|
-
if (!sha) continue;
|
|
341
|
-
// First-parent range for the commit itself. A root commit has no `^`, in
|
|
342
|
-
// which case git's empty-tree hash gives us the whole thing as an add.
|
|
343
|
-
let range = `${sha}^..${sha}`;
|
|
344
|
-
try {
|
|
345
|
-
git(['rev-parse', `${sha}^`], cwd);
|
|
346
|
-
} catch {
|
|
347
|
-
range = `4b825dc642cb6eb9a060e54bf8d69288fbee4904..${sha}`;
|
|
348
|
-
}
|
|
349
|
-
const files = fileDiffs(cwd, null, { range });
|
|
350
|
-
out.push({
|
|
351
|
-
sha,
|
|
352
|
-
message: (message ?? '').slice(0, 500),
|
|
353
|
-
authorName: (authorName ?? '').slice(0, 200),
|
|
354
|
-
authorLogin: null,
|
|
355
|
-
committedAt: committedAt ?? new Date().toISOString(),
|
|
356
|
-
url: null,
|
|
357
|
-
additions: files.reduce((n, f) => n + f.additions, 0),
|
|
358
|
-
deletions: files.reduce((n, f) => n + f.deletions, 0),
|
|
359
|
-
files,
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
return out;
|
|
363
|
-
}
|