flowviant 0.125.22 → 0.125.24
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 +12 -0
- package/bin/cli.mjs +5 -0
- package/bin/lib/agentBrief.mjs +4 -0
- package/bin/lib/agentTaskKinds.mjs +23 -3
- package/bin/lib/agentTaskTypes.mjs +9 -1
- package/bin/lib/artifactContracts.mjs +71 -1
- package/bin/lib/artifactPolicy.mjs +5 -0
- package/bin/lib/artifactPreview.mjs +4 -3
- package/bin/lib/artifacts.mjs +4 -0
- package/bin/lib/codexFencedMount.mjs +3 -0
- package/bin/lib/knowledgeLibrary.mjs +14 -10
- package/bin/lib/pinnedCapture.mjs +58 -7
- package/bin/lib/projectTools.mjs +4 -1
- package/bin/lib/prompts.mjs +11 -4
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeCodex.mjs +8 -6
- package/bin/lib/turnLock.mjs +14 -1
- package/bin/lib/videoEncode.mjs +406 -0
- package/bin/lib/videoRender.mjs +529 -0
- package/bin/lib/work.mjs +1 -0
- package/bin/lib/workAgentTools.mjs +8 -4
- package/bin/lib/workAgentTurnBegun.mjs +6 -2
- package/bin/lib/workAgentTurnExecution.mjs +27 -1
- package/bin/lib/workPlaces.mjs +13 -2
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/worktreeDiff.mjs +154 -55
- package/bin/lib/worktreeHealth.mjs +23 -0
- package/package.json +1 -1
|
@@ -31,6 +31,8 @@ import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryD
|
|
|
31
31
|
import { MODEL, PROJECT_ID } from './config.mjs';
|
|
32
32
|
import { setRuntimeLimit } from './daemonState.mjs';
|
|
33
33
|
import { gitOrNull } from './git.mjs';
|
|
34
|
+
import { assertWorktree } from './worktreeHealth.mjs';
|
|
35
|
+
import { placeTurnLockedByLivePid, recordTurnLock } from './turnLock.mjs';
|
|
34
36
|
import { turnCommits } from './turnReceipts.mjs';
|
|
35
37
|
import { addUsage } from './turnUsage.mjs';
|
|
36
38
|
import { stashCard, stashGaps } from './agentCards.mjs';
|
|
@@ -67,6 +69,7 @@ import { codexSubagentsSafe } from './runtimeCodex.mjs';
|
|
|
67
69
|
import { measuredRuntimeVersion } from './runtimeDetection.mjs';
|
|
68
70
|
import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
|
|
69
71
|
import { createUiReview } from './uiReview.mjs';
|
|
72
|
+
import { createVideoRender } from './videoRender.mjs';
|
|
70
73
|
import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
|
|
71
74
|
|
|
72
75
|
export function createAgentTurnExecution({
|
|
@@ -98,6 +101,7 @@ export function createAgentTurnExecution({
|
|
|
98
101
|
runTurnImpl = runTurn,
|
|
99
102
|
exportModel,
|
|
100
103
|
makeUiReview = createUiReview,
|
|
104
|
+
makeVideoRender = createVideoRender,
|
|
101
105
|
}) {
|
|
102
106
|
const reportDelivery = createDeliveryReporter();
|
|
103
107
|
/** How long the final flush may hold the settle. Bounded because the settle
|
|
@@ -146,6 +150,10 @@ export function createAgentTurnExecution({
|
|
|
146
150
|
return;
|
|
147
151
|
}
|
|
148
152
|
const wt = dir.wt;
|
|
153
|
+
const worktreeRepo = repositoryRun?.sources.find(r => r.wt === wt)?.repoRoot ?? repoRoot;
|
|
154
|
+
assertWorktree(wt, worktreeRepo);
|
|
155
|
+
const turnLock = sessionMetaPath(wt, 'flowviant-turn.lock', place);
|
|
156
|
+
if (placeTurnLockedByLivePid(sessionMetaPath(wt, 'flowviant-turn.lock'))) return;
|
|
149
157
|
/**
|
|
150
158
|
* NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
|
|
151
159
|
* of these exit non-zero in states a real worktree reaches: `symbolic-ref`
|
|
@@ -404,9 +412,18 @@ export function createAgentTurnExecution({
|
|
|
404
412
|
let uiReview = null;
|
|
405
413
|
let uiReviewNote = '';
|
|
406
414
|
let uiReviewEvidence = '';
|
|
415
|
+
// A video card's render tool (videoRender.mjs), and the words a turn
|
|
416
|
+
// gets when it could not be held, so it stops rather than guesses.
|
|
417
|
+
let videoRender = null;
|
|
418
|
+
let videoNote = '';
|
|
407
419
|
const runCli = (args) => {
|
|
408
420
|
if (run.interrupted) return Promise.resolve('');
|
|
421
|
+
// Preparation awaits external work; remeasure before each fresh or
|
|
422
|
+
// resumed spawn, before replacing Codex's own repository discovery.
|
|
423
|
+
assertWorktree(wt, worktreeRepo);
|
|
424
|
+
args.verifiedWorktree = true;
|
|
409
425
|
args = withUiReview(args, uiReviewNote, uiReview?.env);
|
|
426
|
+
args = withUiReview(args, videoNote);
|
|
410
427
|
return runTurnImpl(args);
|
|
411
428
|
};
|
|
412
429
|
/**
|
|
@@ -468,6 +485,10 @@ export function createAgentTurnExecution({
|
|
|
468
485
|
uiReviewEvidence = uiReviewNote;
|
|
469
486
|
}
|
|
470
487
|
}
|
|
488
|
+
if (taskKind === 'video') {
|
|
489
|
+
try { videoRender = await makeVideoRender({ wt, signal: setupController.signal }); }
|
|
490
|
+
catch (error) { videoNote = `The card_video render tool could not be started on this machine: ${envScrub(error.message)}. Without it no GIF, MP4 or WebM can be rendered: say so in your answer and stop.`; }
|
|
491
|
+
}
|
|
471
492
|
if (orchestrating) {
|
|
472
493
|
orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
|
|
473
494
|
assignmentContext: () => agentChildScope(job),
|
|
@@ -478,7 +499,7 @@ export function createAgentTurnExecution({
|
|
|
478
499
|
notify: message => liveInput ? run.live?.inject(message) === true : false,
|
|
479
500
|
taken: id => run.live?.onTaken(id) });
|
|
480
501
|
}
|
|
481
|
-
const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
|
|
502
|
+
const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, videoConfig: videoRender?.config ?? null, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome });
|
|
482
503
|
projectTools = tools.projectTools;
|
|
483
504
|
localCleanup?.begin(turnId);
|
|
484
505
|
const { toolsNote } = tools;
|
|
@@ -656,6 +677,10 @@ export function createAgentTurnExecution({
|
|
|
656
677
|
// (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
|
|
657
678
|
// startup probe and its Terminal tabs.
|
|
658
679
|
onSpawn: (ch) => {
|
|
680
|
+
let releaseTurnLock;
|
|
681
|
+
try { releaseTurnLock = recordTurnLock(turnLock, ch.pid); }
|
|
682
|
+
catch (error) { killGroup(ch); throw error; }
|
|
683
|
+
ch.once('close', releaseTurnLock);
|
|
659
684
|
if (orchestrator && rt === 'codex') run.live?.connect((id, body) => orchestrator.input(id, body));
|
|
660
685
|
if (rt !== 'claude') reportDelivery('agent', job, 'taken');
|
|
661
686
|
brief?.running(true);
|
|
@@ -733,6 +758,7 @@ export function createAgentTurnExecution({
|
|
|
733
758
|
// The builder has finished its fix/recheck loop. Close only resources
|
|
734
759
|
// owned by its review tool, then bind evidence to the final source
|
|
735
760
|
// before the existing artifact upload and delivery settlement.
|
|
761
|
+
try { await videoRender?.close(); } catch { /* a closed listener */ }
|
|
736
762
|
try { await uiReview?.close(); }
|
|
737
763
|
finally {
|
|
738
764
|
try { uiReviewEvidence = uiReview?.finalize() ?? uiReviewEvidence; }
|
package/bin/lib/workPlaces.mjs
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* so a lane that learns a place cannot store one unvalidated.
|
|
14
14
|
*/
|
|
15
15
|
import { readRepositoryPlace } from './repositoryPlaces.mjs';
|
|
16
|
-
import { existsSync, statSync } from 'node:fs';
|
|
16
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
|
|
19
|
+
import { assertWorktree } from './worktreeHealth.mjs';
|
|
19
20
|
|
|
20
21
|
/** The place id meaning "the checkout", not a worktree. Must match the
|
|
21
22
|
* server's REPO_PLACE — it is a wire value, not a local convention. The
|
|
@@ -213,6 +214,7 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
213
214
|
* same way adoption resolves a cwd, and for the same reason.
|
|
214
215
|
*/
|
|
215
216
|
const placeWtFor = (placeId, baseAt) => {
|
|
217
|
+
placeWtFor.lastError = null;
|
|
216
218
|
if (placeId === REPO_PLACE) {
|
|
217
219
|
// The checkout. `fresh: false` on purpose — nothing was opened, so no
|
|
218
220
|
// caller may treat this as a newly-cut branch.
|
|
@@ -220,7 +222,14 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
220
222
|
}
|
|
221
223
|
const wt = worktreePath(baseDir, placeId);
|
|
222
224
|
if (!wt) return null;
|
|
223
|
-
|
|
225
|
+
let fresh;
|
|
226
|
+
try {
|
|
227
|
+
fresh = !existsSync(wt) || readdirSync(wt).length === 0;
|
|
228
|
+
if (!fresh) assertWorktree(wt, repoRoot);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
placeWtFor.lastError = error.message;
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
224
233
|
if (fresh) {
|
|
225
234
|
const branch = sessionBranch(placeId);
|
|
226
235
|
// `baseAt` is the adoption override: a tab born from a terminal session
|
|
@@ -276,6 +285,8 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
276
285
|
// always did (runTurn.mjs spawns with `{...process.env}`), so anything
|
|
277
286
|
// exported in the shell the daemon was started from is present here.
|
|
278
287
|
}
|
|
288
|
+
try { assertWorktree(wt, repoRoot); }
|
|
289
|
+
catch (error) { placeWtFor.lastError = error.message; return null; }
|
|
279
290
|
return { wt, fresh };
|
|
280
291
|
};
|
|
281
292
|
|
package/bin/lib/workRetire.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { git, isSafePathSegment } from './git.mjs';
|
|
14
14
|
import { PERSON_PLACE_PREFIX } from './workPlaces.mjs';
|
|
15
|
+
import { placeTurnLockedByLivePid } from './turnLock.mjs';
|
|
15
16
|
|
|
16
17
|
export function createWorkRetire({
|
|
17
18
|
repositoryPlaces,
|
|
@@ -24,6 +25,7 @@ export function createWorkRetire({
|
|
|
24
25
|
workTokens,
|
|
25
26
|
agentChildren,
|
|
26
27
|
sweepMergedSessionBranch,
|
|
28
|
+
sessionMetaPath,
|
|
27
29
|
}) {
|
|
28
30
|
/**
|
|
29
31
|
* Retire the worktrees of sessions the server says are CLOSED.
|
|
@@ -111,6 +113,8 @@ export function createWorkRetire({
|
|
|
111
113
|
if (placeLocks.has(placeOf(id)) || shipping.has(id)) continue; // still draining here
|
|
112
114
|
const wt = join(dir, id);
|
|
113
115
|
try {
|
|
116
|
+
const places = repositoryPlaces?.stateFor(id) ? repositoryPlaces.entries(id).filter(r => r.writable).map(r => r.wt) : [wt];
|
|
117
|
+
if (places.some(path => placeTurnLockedByLivePid(sessionMetaPath?.(path, 'flowviant-turn.lock')))) continue;
|
|
114
118
|
// Uncommitted work is the human's — a resource sweep does not outrank
|
|
115
119
|
// it, closed tab or not. (The non-force remove would refuse anyway;
|
|
116
120
|
// the explicit check keeps the intent legible.)
|
|
@@ -179,6 +183,7 @@ export function createWorkRetire({
|
|
|
179
183
|
if (placeLocks.has(place)) continue;
|
|
180
184
|
const wt = join(baseDir, 'sessions', place);
|
|
181
185
|
try {
|
|
186
|
+
if (placeTurnLockedByLivePid(sessionMetaPath?.(wt, 'flowviant-turn.lock'))) continue;
|
|
182
187
|
if (git(['status', '--porcelain'], wt).trim() !== '') continue;
|
|
183
188
|
git(['merge', '--ff-only', baseRef()], wt);
|
|
184
189
|
} catch {
|
package/bin/lib/worktreeDiff.mjs
CHANGED
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
* uncommitted work in the same total. Untracked files count too: git calls them
|
|
14
14
|
* nothing until they are added, and a human calls them new work.
|
|
15
15
|
*
|
|
16
|
+
* …AND THAT WHOLE SPLIT IN TWO (2026-10-25). The agent page's Changes counts
|
|
17
|
+
* the branch's COMMITS, so beside the whole it read as a disagreement: "Native
|
|
18
|
+
* 3D viewer" said +78 −12 in Changes and +114 −15 on the branch row (a running
|
|
19
|
+
* card's commit on top), and "New task submission" said "Nothing committed
|
|
20
|
+
* yet" beside +613 −294 of work nobody had committed. `committed` is
|
|
21
|
+
* `<merge-base>..HEAD` — the figure Changes agrees with — and `uncommitted` is
|
|
22
|
+
* HEAD against the working tree, untracked files again counted as additions,
|
|
23
|
+
* with its own rows so the page can list it apart. The halves need not sum to
|
|
24
|
+
* the whole: a line a commit changed and the working tree changed back counts
|
|
25
|
+
* in both and in neither, which is git's arithmetic, relayed.
|
|
26
|
+
*
|
|
16
27
|
* It also carries THIS BRANCH'S OWN COMMITS and the cards they name — see
|
|
17
28
|
* `branchCommits` below. That rides here rather than on a route of its own
|
|
18
29
|
* because this sweep is already standing in the right directory on the right
|
|
@@ -252,16 +263,104 @@ function countLines(buf) {
|
|
|
252
263
|
return s.endsWith('\n') ? n - 1 : n;
|
|
253
264
|
}
|
|
254
265
|
|
|
266
|
+
/** One diffstat row. The path is clamped to the server's zod cap (file path ≤
|
|
267
|
+
* 300 — see the caps at worktreeDiff's return): one over-cap string would 400
|
|
268
|
+
* the whole report batch, and a readout must degrade to a shorter label, never
|
|
269
|
+
* to silence. Null for an empty path, which names nothing. */
|
|
270
|
+
function fileRow(path, added, deleted, binary = false) {
|
|
271
|
+
if (!path) return null;
|
|
272
|
+
path = path.slice(0, 300);
|
|
273
|
+
return binary ? { path, added, deleted, binary } : { path, added, deleted };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* `git diff --numstat -z <revs…> --`, read as rows — the one reader of that
|
|
278
|
+
* output, for the whole and both halves alike. Throws when git does.
|
|
279
|
+
*
|
|
280
|
+
* `-z`, for the reason gitRaw exists: git's LINE-based output C-quotes any path
|
|
281
|
+
* that is not plain ASCII, so an accented filename arrives as "n\303\251w.txt"
|
|
282
|
+
* — a string that is not the path and reads as escaped garbage in the rail.
|
|
283
|
+
* And `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
|
|
284
|
+
* but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
|
|
285
|
+
* then the new one. An empty path is therefore the rename marker and the next
|
|
286
|
+
* two fields belong to it — read line-wise, a rename would report a file
|
|
287
|
+
* literally named "old => new".
|
|
288
|
+
*/
|
|
289
|
+
function numstatRows(revs, wt) {
|
|
290
|
+
const rows = [];
|
|
291
|
+
const fields = splitNul(gitRaw(['diff', '--numstat', '-z', ...revs, '--'], wt));
|
|
292
|
+
for (let i = 0; i < fields.length; i++) {
|
|
293
|
+
const [a, d, ...rest] = fields[i].split('\t');
|
|
294
|
+
let path = rest.join('\t');
|
|
295
|
+
if (!path) {
|
|
296
|
+
path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
|
|
297
|
+
i += 2;
|
|
298
|
+
if (!path) continue;
|
|
299
|
+
}
|
|
300
|
+
const binary = a === '-' || d === '-';
|
|
301
|
+
const row = fileRow(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
|
|
302
|
+
if (row) rows.push(row);
|
|
303
|
+
}
|
|
304
|
+
return rows;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The same rows, or NULL when git would not answer — kept apart from an
|
|
308
|
+
* empty diff, so a failed read omits its half rather than reporting zeros. */
|
|
309
|
+
function numstatOrNull(revs, wt) {
|
|
310
|
+
try {
|
|
311
|
+
return numstatRows(revs, wt);
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A diffstat summed from its rows: totals and `fileCount` over ALL of them,
|
|
318
|
+
* and the biggest MAX_FILES as the rows reported, `truncated` saying how many
|
|
319
|
+
* were left out. */
|
|
320
|
+
function diffstat(rows) {
|
|
321
|
+
// Biggest first: with a 20-row cap, the rows that survive should be the ones
|
|
322
|
+
// worth looking at. Ties break by path so the list doesn't shuffle per sweep.
|
|
323
|
+
const files = [...rows].sort(
|
|
324
|
+
(x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
|
|
325
|
+
);
|
|
326
|
+
let additions = 0;
|
|
327
|
+
let deletions = 0;
|
|
328
|
+
for (const f of files) {
|
|
329
|
+
additions += f.added;
|
|
330
|
+
deletions += f.deleted;
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
additions,
|
|
334
|
+
deletions,
|
|
335
|
+
fileCount: files.length,
|
|
336
|
+
truncated: Math.max(0, files.length - MAX_FILES),
|
|
337
|
+
files: files.slice(0, MAX_FILES),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
255
341
|
/**
|
|
256
342
|
* @param {string} wt the worktree directory
|
|
257
343
|
* @param {string} baseRef the project's base ref (e.g. `origin/main`)
|
|
258
|
-
* @returns {null | {branch:string, path:string, ahead:number,
|
|
259
|
-
*
|
|
344
|
+
* @returns {null | {branch:string, headSha?:string, path:string, ahead:number,
|
|
345
|
+
* behind:number, baseLabel:string,
|
|
346
|
+
* baseCommits:{sha:string, subject:string, author:string}[],
|
|
260
347
|
* dirty:boolean, additions:number, deletions:number, fileCount:number,
|
|
261
348
|
* truncated:number,
|
|
262
349
|
* files:{path:string, added:number, deleted:number, binary?:boolean}[],
|
|
350
|
+
* committed?:{additions:number, deletions:number, fileCount:number},
|
|
351
|
+
* uncommitted?:{additions:number, deletions:number, fileCount:number,
|
|
352
|
+
* truncated:number,
|
|
353
|
+
* files:{path:string, added:number, deleted:number, binary?:boolean}[]},
|
|
263
354
|
* commits:{sha:string, subject:string, author:string, at:string,
|
|
264
355
|
* additions:number, deletions:number, taskIds:string[]}[]}}
|
|
356
|
+
*
|
|
357
|
+
* The top-level additions/deletions/fileCount/truncated/files are the WHOLE
|
|
358
|
+
* (merge-base against the working tree, untracked included) — what the tab
|
|
359
|
+
* rails read. `committed` (`<merge-base>..HEAD`) and `uncommitted` (HEAD
|
|
360
|
+
* against the working tree, untracked included, with its own rows) split it
|
|
361
|
+
* for the agent page, which agrees with the first and lists the second apart.
|
|
362
|
+
* `committed` is absent without a merge-base; either half is absent when its
|
|
363
|
+
* git read failed — unknown, never a fabricated zero.
|
|
265
364
|
*/
|
|
266
365
|
export function worktreeDiff(wt, baseRef) {
|
|
267
366
|
if (!wt || !existsSync(wt)) return null;
|
|
@@ -289,52 +388,24 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
289
388
|
HEAD below, which still reports the uncommitted half honestly */
|
|
290
389
|
}
|
|
291
390
|
|
|
292
|
-
const files = [];
|
|
293
|
-
let additions = 0;
|
|
294
|
-
let deletions = 0;
|
|
295
|
-
const push = (path, added, deleted, binary = false) => {
|
|
296
|
-
if (!path) return;
|
|
297
|
-
// Clamped to the server's zod cap (file path ≤ 300 — see the caps at the
|
|
298
|
-
// return, below): one over-cap string would 400 the whole report batch,
|
|
299
|
-
// and a readout must degrade to a shorter label, never to silence.
|
|
300
|
-
path = path.slice(0, 300);
|
|
301
|
-
files.push(binary ? { path, added, deleted, binary } : { path, added, deleted });
|
|
302
|
-
additions += added;
|
|
303
|
-
deletions += deleted;
|
|
304
|
-
};
|
|
305
|
-
|
|
306
391
|
// Tracked: working tree vs base. `git diff <base>` (no --cached, no second
|
|
307
|
-
// ref) is exactly "everything this session did", committed or not.
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
// `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
|
|
313
|
-
// but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
|
|
314
|
-
// then the new one. An empty path is therefore the rename marker and the
|
|
315
|
-
// next two fields belong to it — read line-wise, a rename would report a
|
|
316
|
-
// file literally named "old => new".
|
|
317
|
-
const fields = splitNul(gitRaw(['diff', '--numstat', '-z', base || 'HEAD', '--'], wt));
|
|
318
|
-
for (let i = 0; i < fields.length; i++) {
|
|
319
|
-
const [a, d, ...rest] = fields[i].split('\t');
|
|
320
|
-
let path = rest.join('\t');
|
|
321
|
-
if (!path) {
|
|
322
|
-
path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
|
|
323
|
-
i += 2;
|
|
324
|
-
if (!path) continue;
|
|
325
|
-
}
|
|
326
|
-
const binary = a === '-' || d === '-';
|
|
327
|
-
push(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
|
|
328
|
-
}
|
|
329
|
-
} catch {
|
|
330
|
-
/* report what we have */
|
|
331
|
-
}
|
|
392
|
+
// ref) is exactly "everything this session did", committed or not. Without a
|
|
393
|
+
// merge-base it is HEAD, which is the uncommitted half alone — kept as rows
|
|
394
|
+
// OR NULL, so that half can reuse this read below without mistaking a failed
|
|
395
|
+
// one for a clean tree.
|
|
396
|
+
const trackedRows = numstatOrNull([base || 'HEAD'], wt);
|
|
332
397
|
|
|
333
398
|
// Untracked, minus everything gitignored — new files are the most visible
|
|
334
399
|
// work a session does and they would otherwise show as nothing at all. `-z`
|
|
335
400
|
// here is not cosmetic: a C-quoted untracked name fails the statSync below,
|
|
336
401
|
// and the catch swallowed the row — a session's brand-new `café.txt` simply
|
|
337
|
-
// vanished from the rail.
|
|
402
|
+
// vanished from the rail. Scanned ONCE: these rows belong to the whole and to
|
|
403
|
+
// the uncommitted half alike, and the scan opens files.
|
|
404
|
+
const untracked = [];
|
|
405
|
+
const pushUntracked = (path, added, binary = false) => {
|
|
406
|
+
const row = fileRow(path, added, 0, binary);
|
|
407
|
+
if (row) untracked.push(row);
|
|
408
|
+
};
|
|
338
409
|
try {
|
|
339
410
|
const others = splitNul(gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], wt));
|
|
340
411
|
for (const path of others.slice(0, MAX_UNTRACKED_SCAN)) {
|
|
@@ -343,12 +414,12 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
343
414
|
const st = statSync(full);
|
|
344
415
|
if (!st.isFile()) continue;
|
|
345
416
|
if (st.size > MAX_COUNT_BYTES) {
|
|
346
|
-
|
|
417
|
+
pushUntracked(path, 0, true);
|
|
347
418
|
continue;
|
|
348
419
|
}
|
|
349
420
|
const lines = countLines(readFileSync(full));
|
|
350
|
-
if (lines === null)
|
|
351
|
-
else
|
|
421
|
+
if (lines === null) pushUntracked(path, 0, true);
|
|
422
|
+
else pushUntracked(path, lines);
|
|
352
423
|
} catch {
|
|
353
424
|
/* vanished between listing and reading — it wasn't there to report */
|
|
354
425
|
}
|
|
@@ -357,6 +428,37 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
357
428
|
/* report what we have */
|
|
358
429
|
}
|
|
359
430
|
|
|
431
|
+
const whole = diffstat([...(trackedRows ?? []), ...untracked]);
|
|
432
|
+
|
|
433
|
+
// THE WHOLE, SPLIT IN TWO — see the header. Each half is its own git read
|
|
434
|
+
// and each degrades alone: a half git would not answer is OMITTED, because
|
|
435
|
+
// a zero there would claim a clean side nobody measured.
|
|
436
|
+
//
|
|
437
|
+
// Committed: `git diff <merge-base> HEAD`, only with a merge-base. Without
|
|
438
|
+
// one there is no "this branch's commits" to count, and the fallback above
|
|
439
|
+
// already diffed HEAD, which is the other half.
|
|
440
|
+
let committed = null;
|
|
441
|
+
if (base) {
|
|
442
|
+
const rows = numstatOrNull([base, 'HEAD'], wt);
|
|
443
|
+
if (rows) {
|
|
444
|
+
const { additions, deletions, fileCount } = diffstat(rows);
|
|
445
|
+
committed = { additions, deletions, fileCount };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Uncommitted: `git diff HEAD` plus the untracked rows. An UNBORN HEAD has
|
|
449
|
+
// no commit, so everything there is uncommitted and the half is the whole —
|
|
450
|
+
// `git diff HEAD` fails on it, and that failure must not drop the half
|
|
451
|
+
// (defensive today: the branch read above already returns null on an unborn
|
|
452
|
+
// HEAD). With no merge-base the whole's tracked read already WAS `git diff
|
|
453
|
+
// HEAD`, so it is reused rather than run twice.
|
|
454
|
+
let uncommitted = null;
|
|
455
|
+
if (!headSha) {
|
|
456
|
+
uncommitted = { ...whole };
|
|
457
|
+
} else {
|
|
458
|
+
const rows = base ? numstatOrNull(['HEAD'], wt) : trackedRows;
|
|
459
|
+
if (rows) uncommitted = diffstat([...rows, ...untracked]);
|
|
460
|
+
}
|
|
461
|
+
|
|
360
462
|
let ahead = 0;
|
|
361
463
|
try {
|
|
362
464
|
if (base) ahead = Number(git(['rev-list', '--count', `${base}..HEAD`], wt)) || 0;
|
|
@@ -403,11 +505,6 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
403
505
|
/* leave at false */
|
|
404
506
|
}
|
|
405
507
|
|
|
406
|
-
// Biggest first: with a 20-row cap, the rows that survive should be the ones
|
|
407
|
-
// worth looking at. Ties break by path so the list doesn't shuffle per sweep.
|
|
408
|
-
files.sort(
|
|
409
|
-
(x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
|
|
410
|
-
);
|
|
411
508
|
// Every string here is clamped to the server's own zod caps (branch ≤ 200,
|
|
412
509
|
// baseLabel ≤ 120, subject ≤ 200, author ≤ 80, file path ≤ 300): the report
|
|
413
510
|
// rides in a BATCH, so a single over-cap string — a generated branch name, a
|
|
@@ -421,11 +518,13 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
421
518
|
baseLabel: String(baseRef).replace(/^origin\//, '').slice(0, 120),
|
|
422
519
|
baseCommits,
|
|
423
520
|
dirty,
|
|
424
|
-
additions,
|
|
425
|
-
deletions,
|
|
426
|
-
fileCount:
|
|
427
|
-
truncated:
|
|
428
|
-
files: files
|
|
521
|
+
additions: whole.additions,
|
|
522
|
+
deletions: whole.deletions,
|
|
523
|
+
fileCount: whole.fileCount,
|
|
524
|
+
truncated: whole.truncated,
|
|
525
|
+
files: whole.files,
|
|
526
|
+
...(committed ? { committed } : {}),
|
|
527
|
+
...(uncommitted ? { uncommitted } : {}),
|
|
429
528
|
commits: branchCommits(wt, base),
|
|
430
529
|
};
|
|
431
530
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Verify the directory itself, rather than a parent git happened to discover. */
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { git } from './git.mjs';
|
|
5
|
+
|
|
6
|
+
export function assertWorktree(wt, repoRoot) {
|
|
7
|
+
try {
|
|
8
|
+
const top = realpathSync(git(['rev-parse', '--show-toplevel'], wt));
|
|
9
|
+
if (top !== realpathSync(wt)) throw new Error('the directory is not a git worktree root');
|
|
10
|
+
const common = (dir) => realpathSync(resolve(dir, git(['rev-parse', '--git-common-dir'], dir)));
|
|
11
|
+
if (common(wt) !== common(repoRoot)) throw new Error('the worktree belongs to another repository');
|
|
12
|
+
const registered = git(['worktree', 'list', '--porcelain', '-z'], repoRoot).split('\0')
|
|
13
|
+
.some(line => {
|
|
14
|
+
if (!line.startsWith('worktree ')) return false;
|
|
15
|
+
try { return realpathSync(line.slice(9)) === top; } catch { return false; }
|
|
16
|
+
});
|
|
17
|
+
if (!registered) throw new Error('the worktree is not registered with this repository');
|
|
18
|
+
git(['rev-parse', '--verify', 'HEAD'], wt);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const why = error.stderr?.toString().trim() || error.message;
|
|
21
|
+
throw new Error(`The worktree at ${wt} is unavailable: ${why}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.125.
|
|
3
|
+
"version": "0.125.24",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {
|