flowviant 0.97.0 → 0.100.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/README.md +15 -13
- package/bin/cli.mjs +108 -28
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +36 -9
- package/bin/lib/credentials.mjs +29 -5
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +121 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +129 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +36 -12
- package/bin/lib/machines.mjs +24 -14
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +114 -25
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1151 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -3
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/** Session branch shipping and settlement. */
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { git, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
6
|
+
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
7
|
+
import { c, note, ok, warn } from './ui.mjs';
|
|
8
|
+
import { scrub as envScrub } from './env.mjs';
|
|
9
|
+
|
|
10
|
+
export function createWorkShipper({
|
|
11
|
+
inPlace,
|
|
12
|
+
placeOf,
|
|
13
|
+
settleShip,
|
|
14
|
+
REPO_PLACE,
|
|
15
|
+
repoRoot,
|
|
16
|
+
baseDir,
|
|
17
|
+
gitNet,
|
|
18
|
+
baseRef,
|
|
19
|
+
placeWtFor,
|
|
20
|
+
turnLockedByLivePid,
|
|
21
|
+
sessionMetaPath,
|
|
22
|
+
state,
|
|
23
|
+
}) {
|
|
24
|
+
const { shipping, pendingShipReports } = state;
|
|
25
|
+
|
|
26
|
+
// Ship — a session's branch merging to main, on the human's word.
|
|
27
|
+
//
|
|
28
|
+
// --no-ff, NEVER squash: every delivered card carries commit shas as its
|
|
29
|
+
// receipts, and a squash would point them all at commits that no longer
|
|
30
|
+
// exist on main. Sequence: idempotency FIRST (a re-offered job after a lost
|
|
31
|
+
// report recovers its receipts and re-reports — it must never re-merge, and
|
|
32
|
+
// never be refused by checks that judge a merge this job already made).
|
|
33
|
+
// Then two paths. A LIVE session: re-open the worktree if it was retired,
|
|
34
|
+
// defer while a turn's CLI holds it, refuse a dirty worktree
|
|
35
|
+
// (auto-committing someone's mid-thought state is not shipping, it is
|
|
36
|
+
// guessing), refuse a worktree that left its own branch, fold main INTO the
|
|
37
|
+
// branch first so conflicts surface where the session can resolve them,
|
|
38
|
+
// then merge THE RESOLVED TIP outward through a throwaway worktree so
|
|
39
|
+
// nobody's checkout moves — receipts and merged ref are the same sha by
|
|
40
|
+
// construction. An ENDED session (absent from the roster's
|
|
41
|
+
// activeWorkSessions): the BRANCH is the session now — nobody can commit,
|
|
42
|
+
// discard, or resolve anything in its directory, so the checks whose
|
|
43
|
+
// remedies address a live tab don't apply; merge the tip directly through
|
|
44
|
+
// the throwaway, and a conflict fails honestly. Every exit reports
|
|
45
|
+
// ship-done exactly once — except a deliberate deferral, re-offered next
|
|
46
|
+
// poll; a ship that failed silently leaves the human believing their work
|
|
47
|
+
// is on main.
|
|
48
|
+
const processShipJobs = (jobs, activeIds) => {
|
|
49
|
+
// Field absent (older server) = no liveness signal: treat every session
|
|
50
|
+
// as live, which keeps the stricter checks.
|
|
51
|
+
const liveIds = Array.isArray(activeIds) ? new Set(activeIds) : null;
|
|
52
|
+
for (const job of jobs ?? []) {
|
|
53
|
+
if (!job || typeof job.sessionId !== 'string') continue;
|
|
54
|
+
if (shipping.has(job.sessionId)) continue;
|
|
55
|
+
// The merge already LANDED and only the report is owed — flushing
|
|
56
|
+
// delivers it; re-running the ship would misread its own success.
|
|
57
|
+
if (pendingShipReports.has(job.sessionId)) continue;
|
|
58
|
+
shipping.add(job.sessionId);
|
|
59
|
+
// A WRITER, keyed by PLACE: ship folds and merges with git in this
|
|
60
|
+
// directory, and no CLI running here can coordinate with that. Keyed by
|
|
61
|
+
// place rather than by session id, which is what this used to do while
|
|
62
|
+
// turns keyed on the place — so the two never shared a lock and the
|
|
63
|
+
// guarantee in this comment was not actually held. `shipping` (above)
|
|
64
|
+
// keeps overlapping polls from queueing the same job twice.
|
|
65
|
+
inPlace(placeOf(job.sessionId), true, async () => {
|
|
66
|
+
let settled = false;
|
|
67
|
+
let deferred = false;
|
|
68
|
+
const done = async (payload) => {
|
|
69
|
+
if (settled) return;
|
|
70
|
+
settled = true;
|
|
71
|
+
await settleShip(job.sessionId, payload);
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
if (!isSafePathSegment(job.sessionId)) {
|
|
75
|
+
await done({ ok: false, error: 'invalid session id' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
|
|
79
|
+
/**
|
|
80
|
+
* WHAT IS ACTUALLY CHECKED OUT — not what we named it at birth.
|
|
81
|
+
*
|
|
82
|
+
* Ship used to compute `session/<id>` and then REFUSE if HEAD had
|
|
83
|
+
* moved: "ask it to return to its session branch, then ship again".
|
|
84
|
+
* That refusal is the thing this product says it never does — it had
|
|
85
|
+
* no reason of its own beyond bookkeeping, and in a terminal
|
|
86
|
+
* `git checkout -b` breaks nothing, which is the whole standard this
|
|
87
|
+
* surface is held to.
|
|
88
|
+
*
|
|
89
|
+
* The bug it was written for was real and is fixed properly here
|
|
90
|
+
* rather than frozen out: ship once merged the branch NAME while
|
|
91
|
+
* logging HEAD, so receipts named commits that never landed on main.
|
|
92
|
+
* That was TWO SOURCES OF TRUTH, not branch switching. There is one
|
|
93
|
+
* now, and it is the worktree's own HEAD.
|
|
94
|
+
*
|
|
95
|
+
* Resolved BEFORE the idempotency check below, and that ordering is
|
|
96
|
+
* load-bearing: `session/<id>` can still exist, stale and already an
|
|
97
|
+
* ancestor of base, while the real work sits on the branch that was
|
|
98
|
+
* checked out afterwards. Asking the old name first would answer
|
|
99
|
+
* "already merged — nothing new to ship" over unshipped commits.
|
|
100
|
+
*
|
|
101
|
+
* A directory that is gone (a retired or closed tab) cannot be asked,
|
|
102
|
+
* so the recorded name is the fallback — the one case where the name
|
|
103
|
+
* is the only thing there is.
|
|
104
|
+
*/
|
|
105
|
+
// The session's PLACE — the directory it actually works in.
|
|
106
|
+
const shipPlace = placeOf(job.sessionId);
|
|
107
|
+
const wt = shipPlace === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', shipPlace);
|
|
108
|
+
let branch = `session/${job.sessionId}`;
|
|
109
|
+
let detached = false;
|
|
110
|
+
if (existsSync(wt)) {
|
|
111
|
+
try {
|
|
112
|
+
branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
|
|
113
|
+
} catch {
|
|
114
|
+
detached = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// THE ONE REFUSAL LEFT, and it is not policy. A detached HEAD names
|
|
118
|
+
// no branch, so there is nothing to merge and nothing to record —
|
|
119
|
+
// that is an ambiguity in git, not a rule of ours.
|
|
120
|
+
if (detached) {
|
|
121
|
+
await done({
|
|
122
|
+
ok: false,
|
|
123
|
+
error:
|
|
124
|
+
'this session is on a detached HEAD — no branch to ship. Ask it to check out a branch, then ship again',
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
let branchExists = true;
|
|
129
|
+
try {
|
|
130
|
+
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot);
|
|
131
|
+
} catch {
|
|
132
|
+
branchExists = false;
|
|
133
|
+
}
|
|
134
|
+
// "Nothing to ship" is a statement about the BRANCH. A retired
|
|
135
|
+
// directory is not a missing session — retirement promises that
|
|
136
|
+
// committed work survives, and ship re-attaches below to keep it.
|
|
137
|
+
if (!existsSync(wt) && !branchExists) {
|
|
138
|
+
await done({
|
|
139
|
+
ok: false,
|
|
140
|
+
error: 'nothing to ship — this session has no branch on this machine',
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
gitNet(['fetch', 'origin', '--quiet'], 60_000);
|
|
146
|
+
} catch {
|
|
147
|
+
/* offline fetch — merge against what we have */
|
|
148
|
+
}
|
|
149
|
+
const ancestorOfBase = (ref) => {
|
|
150
|
+
try {
|
|
151
|
+
git(['merge-base', '--is-ancestor', ref, baseRef()], repoRoot);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
// The machine may have no git identity, and a merge COMMIT needs
|
|
158
|
+
// one. Prefer the user's own config; fall back to the daemon's (the
|
|
159
|
+
// same fallback ship's merge keeps) so a bare machine doesn't fail
|
|
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
|
+
});
|
|
179
|
+
// Receipts for a range: --no-merges, because fold commits describe
|
|
180
|
+
// plumbing, not work.
|
|
181
|
+
const logCommits = (range) =>
|
|
182
|
+
git(['log', range, '--no-merges', '--format=%H%x09%s'], repoRoot)
|
|
183
|
+
.split('\n')
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.map((l) => {
|
|
186
|
+
const [sha, ...rest] = l.split('\t');
|
|
187
|
+
return { sha, subject: envScrub(rest.join('\t')).slice(0, 200) };
|
|
188
|
+
});
|
|
189
|
+
// Merge outward through a throwaway worktree so no checkout moves.
|
|
190
|
+
// The throwaway dies on EVERY exit — success, conflict or throw —
|
|
191
|
+
// or the next ship of this session trips over its corpse.
|
|
192
|
+
// Carry the tip out onto base and push it. See `shipMerge.mjs` for
|
|
193
|
+
// the throwaway-worktree shape, the one retry when two people ship at
|
|
194
|
+
// once, and why the operator's own branch is fast-forwarded after.
|
|
195
|
+
const mergeOutward = (tip, count) =>
|
|
196
|
+
shipMergeOutward({
|
|
197
|
+
tip,
|
|
198
|
+
count,
|
|
199
|
+
branch,
|
|
200
|
+
label: job.sessionName || job.sessionId.slice(0, 8),
|
|
201
|
+
git,
|
|
202
|
+
gitMerge,
|
|
203
|
+
repoRoot,
|
|
204
|
+
tmpDir: join(baseDir, 'ship', job.sessionId),
|
|
205
|
+
baseRef,
|
|
206
|
+
workingTree: placeWtFor(shipPlace)?.wt ?? null,
|
|
207
|
+
warn,
|
|
208
|
+
});
|
|
209
|
+
// Idempotency: base already contains the branch tip. A re-offered
|
|
210
|
+
// job after a lost report lands here — never a re-merge, and never
|
|
211
|
+
// "nothing to ship" AS A FAILURE for work that in fact shipped. The
|
|
212
|
+
// receipts must not die with the lost report: the --no-ff merge
|
|
213
|
+
// commit that carried the tip in holds it as its SECOND parent, so
|
|
214
|
+
// the original commit list is recoverable — settling with none
|
|
215
|
+
// would silently skip the reconciliation backstop for this branch.
|
|
216
|
+
if (branchExists && ancestorOfBase(branch)) {
|
|
217
|
+
const tip = git(['rev-parse', branch], repoRoot);
|
|
218
|
+
let commits = [];
|
|
219
|
+
try {
|
|
220
|
+
const m = git(['log', baseRef(), '--merges', '--format=%H %P', '-n', '500'], repoRoot)
|
|
221
|
+
.split('\n')
|
|
222
|
+
.map((l) => l.trim().split(' '))
|
|
223
|
+
.find((p) => p.length >= 3 && p[2] === tip);
|
|
224
|
+
if (m) commits = logCommits(`${m[1]}..${tip}`);
|
|
225
|
+
} catch {
|
|
226
|
+
/* recovery is best-effort — an ok ship with no receipts beats a false failure */
|
|
227
|
+
}
|
|
228
|
+
await done({
|
|
229
|
+
ok: true,
|
|
230
|
+
commits,
|
|
231
|
+
note: `${baseBranchName(baseRef())} already contains this session's branch — nothing new to merge`,
|
|
232
|
+
});
|
|
233
|
+
ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const ended = liveIds ? !liveIds.has(job.sessionId) : false;
|
|
237
|
+
if (ended) {
|
|
238
|
+
// The tab is closed: no turn can commit, discard, or resolve
|
|
239
|
+
// anything in the directory, so a dirty worktree must not strand
|
|
240
|
+
// the branch's committed work in review forever. Ship the TIP.
|
|
241
|
+
if (!branchExists) {
|
|
242
|
+
await done({
|
|
243
|
+
ok: false,
|
|
244
|
+
error: 'nothing to ship — this session has no branch on this machine',
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const tip = git(['rev-parse', branch], repoRoot);
|
|
249
|
+
const commits = logCommits(`${baseRef()}..${tip}`);
|
|
250
|
+
if (commits.length === 0) {
|
|
251
|
+
await done({
|
|
252
|
+
ok: false,
|
|
253
|
+
error: 'nothing to ship — no commits on the session branch',
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
mergeOutward(tip, commits.length);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
|
|
261
|
+
if (/conflict/i.test(detail)) {
|
|
262
|
+
await done({
|
|
263
|
+
ok: false,
|
|
264
|
+
error:
|
|
265
|
+
'conflicts with main — the tab is closed, so open a new session from this branch to resolve them, then ship again',
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
const line = envScrub(
|
|
269
|
+
String(detail)
|
|
270
|
+
.split('\n')
|
|
271
|
+
.find((l) => l.trim()) ?? 'git merge failed'
|
|
272
|
+
);
|
|
273
|
+
await done({ ok: false, error: `the merge failed: ${line.slice(0, 300)}` });
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
await done({ ok: true, commits });
|
|
278
|
+
ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const dir = placeWtFor(shipPlace);
|
|
282
|
+
if (!dir) {
|
|
283
|
+
await done({
|
|
284
|
+
ok: false,
|
|
285
|
+
error: 'the session worktree could not be opened on this machine',
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// A live CLI is in this worktree — a restarted daemon's orphan
|
|
290
|
+
// mid-turn (in-process the chain serializes, but the lock is the
|
|
291
|
+
// only guarantee that survives a crash). Folding under it would
|
|
292
|
+
// rewrite HEAD inside a held conversation; defer like the turn
|
|
293
|
+
// path, and the job re-offers next poll.
|
|
294
|
+
if (turnLockedByLivePid(sessionMetaPath(dir.wt, 'flowviant-turn.lock'))) {
|
|
295
|
+
warn(
|
|
296
|
+
`a turn is still running in "${job.sessionName || job.sessionId}" — ship waits for it`
|
|
297
|
+
);
|
|
298
|
+
deferred = true;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (git(['status', '--porcelain'], dir.wt) !== '') {
|
|
302
|
+
await done({
|
|
303
|
+
ok: false,
|
|
304
|
+
error:
|
|
305
|
+
'the session has uncommitted changes — ask it to commit or discard them first',
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// NO "return to your session branch" GUARD. `branch` was read from
|
|
310
|
+
// this worktree's HEAD above, so the fold, the tip and the receipts
|
|
311
|
+
// below all name the same thing by construction — which is what the
|
|
312
|
+
// old guard was really protecting, and it protected it by refusing
|
|
313
|
+
// instead of by measuring.
|
|
314
|
+
// Fold main into the branch FIRST: conflicts land here, in the
|
|
315
|
+
// session's own worktree, where the next turn can resolve them.
|
|
316
|
+
try {
|
|
317
|
+
gitMerge(['merge', '--no-edit', baseRef()], dir.wt);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
|
|
320
|
+
// NEVER leave the session mid-merge: a MERGE_HEAD left behind puts
|
|
321
|
+
// every later turn inside someone else's half-finished merge.
|
|
322
|
+
try {
|
|
323
|
+
git(['merge', '--abort'], dir.wt);
|
|
324
|
+
} catch {
|
|
325
|
+
/* nothing in progress */
|
|
326
|
+
}
|
|
327
|
+
if (/conflict/i.test(detail)) {
|
|
328
|
+
await done({
|
|
329
|
+
ok: false,
|
|
330
|
+
error: 'conflicts with main — ask the session to resolve them, then ship again',
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
// An honest error beats a fabricated conflict — the human can
|
|
334
|
+
// only fix what they are told about.
|
|
335
|
+
const line = envScrub(
|
|
336
|
+
String(detail)
|
|
337
|
+
.split('\n')
|
|
338
|
+
.find((l) => l.trim()) ?? 'git merge failed'
|
|
339
|
+
);
|
|
340
|
+
await done({ ok: false, error: `the merge failed: ${line.slice(0, 300)}` });
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// Resolve the EXACT sha to merge, then compute the receipts from it:
|
|
345
|
+
// one X for both, so the ledger can never carry receipts for commits
|
|
346
|
+
// that did not land.
|
|
347
|
+
const tip = git(['rev-parse', branch], repoRoot);
|
|
348
|
+
const commits = logCommits(`${baseRef()}..${tip}`);
|
|
349
|
+
if (commits.length === 0) {
|
|
350
|
+
// Post-fold this is nearly unreachable (a zero-commit branch is an
|
|
351
|
+
// ancestor of base, settled above) — but if the branch's commits
|
|
352
|
+
// all exist on main already, say so truthfully.
|
|
353
|
+
if (ancestorOfBase(tip)) {
|
|
354
|
+
await done({ ok: true, commits: [], note: 'already merged — nothing new to ship' });
|
|
355
|
+
} else {
|
|
356
|
+
await done({
|
|
357
|
+
ok: false,
|
|
358
|
+
error: 'nothing to ship — no commits on the session branch',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
mergeOutward(tip, commits.length);
|
|
364
|
+
await done({ ok: true, commits });
|
|
365
|
+
ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
|
|
366
|
+
} catch (e) {
|
|
367
|
+
warn(`ship failed: ${e?.message ?? e}`);
|
|
368
|
+
await done({
|
|
369
|
+
ok: false,
|
|
370
|
+
error: envScrub(String(e?.message ?? 'the merge failed')).slice(0, 500),
|
|
371
|
+
});
|
|
372
|
+
} finally {
|
|
373
|
+
if (!settled && !deferred) {
|
|
374
|
+
// Belt over braces: NO exit path may leave the ship unreported —
|
|
375
|
+
// a deferral is the one deliberate exception, re-offered next poll.
|
|
376
|
+
await done({ ok: false, error: 'the ship did not complete — check the daemon log' });
|
|
377
|
+
}
|
|
378
|
+
shipping.delete(job.sessionId);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
return { processShipJobs };
|
|
385
|
+
}
|
package/bin/lib/worktreeDiff.mjs
CHANGED
|
@@ -55,12 +55,26 @@ const MAX_COMMITS = 50;
|
|
|
55
55
|
* a plausible id is dropped here rather than shipped: the server drops unknown
|
|
56
56
|
* ids too, but a readout should not spend a request on obvious noise.
|
|
57
57
|
*/
|
|
58
|
+
/** A trailer line is short; one past this is not a trailer, and is never fed
|
|
59
|
+
* to a pattern at all. */
|
|
60
|
+
const MAX_TRAILER_LINE = 2000;
|
|
61
|
+
|
|
58
62
|
export function taskIdsFromMessage(body) {
|
|
59
63
|
const ids = [];
|
|
60
|
-
for (const
|
|
61
|
-
|
|
64
|
+
for (const rawLine of String(body || '').split('\n')) {
|
|
65
|
+
// LINEAR, NEVER BACKTRACKING (audit 2026-09-24). This was
|
|
66
|
+
// `/^\s*Flowviant-Task\s*:\s*(.+?)\s*$/i`, whose lazy group against a
|
|
67
|
+
// trailing `\s*$` is QUADRATIC in the line: `Flowviant-Task: a` + 900k
|
|
68
|
+
// spaces + `b` — one commit any agent can write — held the daemon's event
|
|
69
|
+
// loop for minutes on every 60s sweep (and the landed observer re-parses
|
|
70
|
+
// it after a restart), so the machine read as offline. A length cap first,
|
|
71
|
+
// then an anchored prefix with no ambiguity, then plain string work.
|
|
72
|
+
if (rawLine.length > MAX_TRAILER_LINE) continue;
|
|
73
|
+
const line = rawLine.trim();
|
|
74
|
+
const m = /^Flowviant-Task[ \t]*:/i.exec(line);
|
|
62
75
|
if (!m) continue;
|
|
63
|
-
for (const raw of m[
|
|
76
|
+
for (const raw of line.slice(m[0].length).split(/[\s,]+/)) {
|
|
77
|
+
if (!raw || raw.length > 80) continue; // an id is at most 64
|
|
64
78
|
const id = raw.replace(/^[#<]+|[>,.]+$/g, '');
|
|
65
79
|
if (id && id.length <= 64 && /^[A-Za-z0-9_-]+$/.test(id) && !ids.includes(id)) {
|
|
66
80
|
ids.push(id);
|
|
@@ -100,58 +114,100 @@ export const stripDelims = (s) => String(s ?? '').replace(/[\x1e\x1f]/g, '');
|
|
|
100
114
|
* sha is the same forgery wearing a real commit's name, and the delimiter
|
|
101
115
|
* bytes are stripped from every surviving field.
|
|
102
116
|
*/
|
|
117
|
+
/**
|
|
118
|
+
* ONE COMMIT'S OWN RECORD, asked for BY SHA (audit 2026-09-24).
|
|
119
|
+
*
|
|
120
|
+
* The batched log above (`%H%x1f…%B%x1e` over the whole range) could be forged
|
|
121
|
+
* even with the rev-list set as authority: `git log` is newest first, so a
|
|
122
|
+
* commit whose body carried a fabricated record NAMING AN OLDER REAL SHA was
|
|
123
|
+
* parsed before that commit's own record, the "first record wins" rule kept the
|
|
124
|
+
* forgery, and the real one was dropped as a repeat — re-attributing somebody
|
|
125
|
+
* else's commit to other cards under a forged author and suppressing its real
|
|
126
|
+
* trailer. Asking git for one sha at a time makes the association structural:
|
|
127
|
+
* whatever this record says, it is about the sha we named, so a body can only
|
|
128
|
+
* ever speak for its own commit — which is what a trailer is.
|
|
129
|
+
*
|
|
130
|
+
* The body is the LAST field, so a NUL inside it (git refuses one in a message;
|
|
131
|
+
* a hand-built object might not) is body; the numstat follows it. Returns null
|
|
132
|
+
* when git will not answer.
|
|
133
|
+
*/
|
|
134
|
+
export function commitRecord(sha, cwd, { numstat = false } = {}) {
|
|
135
|
+
let out;
|
|
136
|
+
try {
|
|
137
|
+
out = gitRaw(
|
|
138
|
+
[
|
|
139
|
+
'show',
|
|
140
|
+
'--no-color',
|
|
141
|
+
...(numstat ? ['--numstat'] : ['-s']),
|
|
142
|
+
'--format=%an%x00%aI%x00%B%x00',
|
|
143
|
+
sha,
|
|
144
|
+
'--',
|
|
145
|
+
],
|
|
146
|
+
cwd
|
|
147
|
+
);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const parts = out.split('\0');
|
|
152
|
+
if (parts.length < 4) return null;
|
|
153
|
+
const author = parts[0];
|
|
154
|
+
const at = parts[1];
|
|
155
|
+
const body = parts.slice(2, -1).join('\n');
|
|
156
|
+
// %s is the subject PARAGRAPH with its newlines folded, derived here from
|
|
157
|
+
// the body rather than asked for as a field a message could shift.
|
|
158
|
+
const subject = body.split(/\n[ \t]*\n/)[0].replace(/\s*\n\s*/g, ' ').trim();
|
|
159
|
+
let additions = 0;
|
|
160
|
+
let deletions = 0;
|
|
161
|
+
if (numstat) {
|
|
162
|
+
for (const l of parts[parts.length - 1].split('\n')) {
|
|
163
|
+
if (!l.trim()) continue;
|
|
164
|
+
const [a, d] = l.split('\t');
|
|
165
|
+
if (a === '-' || d === '-') continue; // binary
|
|
166
|
+
additions += Number(a) || 0;
|
|
167
|
+
deletions += Number(d) || 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { sha, subject, author, at, body, additions, deletions };
|
|
171
|
+
}
|
|
172
|
+
|
|
103
173
|
function branchCommits(wt, base) {
|
|
104
174
|
if (!base) return [];
|
|
105
175
|
const out = [];
|
|
106
176
|
try {
|
|
107
|
-
const real =
|
|
108
|
-
|
|
177
|
+
const real = git(['rev-list', '--no-merges', '-n', String(MAX_COMMITS), `${base}..HEAD`], wt)
|
|
178
|
+
.split('\n')
|
|
179
|
+
.filter((x) => /^[0-9a-f]{40,64}$/.test(x));
|
|
180
|
+
if (real.length === 0) return [];
|
|
181
|
+
// One cheap look first: git's own grep names the commits whose message
|
|
182
|
+
// mentions the trailer at all, so a branch that never uses it pays one
|
|
183
|
+
// exec per sweep rather than one per commit, and no body is ever pulled
|
|
184
|
+
// through a pipe just to be searched. Only a HINT — each named commit is
|
|
185
|
+
// then read as itself below, and a sha outside the rev-list set is ignored.
|
|
186
|
+
const named = new Set(
|
|
187
|
+
git(
|
|
188
|
+
['log', '--no-merges', '-i', '--grep=flowviant-task', '--format=%H', `${base}..HEAD`],
|
|
189
|
+
wt
|
|
190
|
+
)
|
|
109
191
|
.split('\n')
|
|
110
192
|
.filter(Boolean)
|
|
111
193
|
);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
'--format=%H%x1f%s%x1f%an%x1f%aI%x1f%B%x1e',
|
|
120
|
-
`${base}..HEAD`,
|
|
121
|
-
],
|
|
122
|
-
wt
|
|
123
|
-
);
|
|
124
|
-
for (const rec of raw.split('\x1e')) {
|
|
125
|
-
const line = rec.replace(/^\n+/, '');
|
|
126
|
-
if (!line.trim()) continue;
|
|
127
|
-
const [sha, subject, author, at, ...bodyParts] = line.split('\x1f');
|
|
128
|
-
if (!real.has(sha)) continue;
|
|
129
|
-
real.delete(sha);
|
|
130
|
-
const taskIds = taskIdsFromMessage(stripDelims(bodyParts.join('\n')));
|
|
194
|
+
for (const sha of real) {
|
|
195
|
+
if (!named.has(sha)) continue;
|
|
196
|
+
// A commit we cannot stat (a numstat past the pipe's buffer) still names
|
|
197
|
+
// its cards — send it with zero counts rather than drop it, as before.
|
|
198
|
+
const rec = commitRecord(sha, wt, { numstat: true }) ?? commitRecord(sha, wt);
|
|
199
|
+
if (!rec) continue;
|
|
200
|
+
const taskIds = taskIdsFromMessage(stripDelims(rec.body));
|
|
131
201
|
if (taskIds.length === 0) continue;
|
|
132
|
-
let additions = 0;
|
|
133
|
-
let deletions = 0;
|
|
134
|
-
try {
|
|
135
|
-
const stat = git(['show', '--numstat', '--format=', sha], wt);
|
|
136
|
-
for (const l of stat.split('\n')) {
|
|
137
|
-
if (!l.trim()) continue;
|
|
138
|
-
const [a, d] = l.split('\t');
|
|
139
|
-
if (a === '-' || d === '-') continue; // binary
|
|
140
|
-
additions += Number(a) || 0;
|
|
141
|
-
deletions += Number(d) || 0;
|
|
142
|
-
}
|
|
143
|
-
} catch {
|
|
144
|
-
/* a commit we cannot stat still names its cards — send it anyway */
|
|
145
|
-
}
|
|
146
202
|
out.push({
|
|
147
203
|
// Clamped to the server's zod caps, same rule as everything else in
|
|
148
204
|
// this file: one over-cap string 400s the whole batch.
|
|
149
205
|
sha: sha.slice(0, 64),
|
|
150
|
-
subject: stripDelims(subject).slice(0, 200),
|
|
151
|
-
author: stripDelims(author).slice(0, 80),
|
|
152
|
-
at: stripDelims(at).slice(0, 40),
|
|
153
|
-
additions,
|
|
154
|
-
deletions,
|
|
206
|
+
subject: stripDelims(rec.subject).slice(0, 200),
|
|
207
|
+
author: stripDelims(rec.author).slice(0, 80),
|
|
208
|
+
at: stripDelims(rec.at).slice(0, 40),
|
|
209
|
+
additions: rec.additions,
|
|
210
|
+
deletions: rec.deletions,
|
|
155
211
|
taskIds: taskIds.slice(0, 8),
|
|
156
212
|
});
|
|
157
213
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.100.0",
|
|
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": {
|
|
7
7
|
"flowviant": "bin/cli.mjs"
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"test": "node --test bin/lib/*.test.mjs"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.0",
|
|
23
22
|
"libsodium-wrappers-sumo": "^0.8.4",
|
|
24
23
|
"ws": "^8.18.0"
|
|
25
24
|
},
|