flowviant 0.78.0 → 0.79.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 +5 -1
- package/bin/lib/authproxy.mjs +101 -32
- package/bin/lib/claude.mjs +1 -18
- package/bin/lib/deploy.mjs +13 -1
- package/bin/lib/env.mjs +37 -24
- package/bin/lib/fleet.mjs +26 -37
- package/bin/lib/git.mjs +0 -263
- package/bin/lib/grant.mjs +24 -3
- package/bin/lib/landed.mjs +69 -22
- package/bin/lib/preview.mjs +35 -16
- package/bin/lib/prompts.mjs +25 -26
- package/bin/lib/shipSweep.mjs +16 -5
- package/bin/lib/work.mjs +411 -50
- package/bin/lib/worktreeDiff.mjs +55 -20
- package/package.json +1 -1
package/bin/lib/git.mjs
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/** Git worktree helpers (fleet & static-fleet modes). */
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
-
import { resolve, join } from 'node:path';
|
|
6
|
-
import { rmSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
|
-
import { materializedFiles } from './env.mjs';
|
|
9
4
|
|
|
10
5
|
export function git(args, cwd) {
|
|
11
6
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
@@ -144,161 +139,6 @@ export function baseBranchName(baseRef) {
|
|
|
144
139
|
return String(baseRef || '').replace(/^origin\//, '') || 'main';
|
|
145
140
|
}
|
|
146
141
|
|
|
147
|
-
/**
|
|
148
|
-
* Get a detached worktree at `wt`, creating it at `ref` if it isn't there.
|
|
149
|
-
*
|
|
150
|
-
* Returns `{ path, fresh }` — `fresh` is the whole point. A worktree that
|
|
151
|
-
* ALREADY existed is one somebody was mid-way through, and the caller must not
|
|
152
|
-
* reset it; a freshly created one is at base by construction and has nothing to
|
|
153
|
-
* preserve. That single bit replaces the in-memory `resuming` flag and the
|
|
154
|
-
* on-disk task marker for the common case, because once a worktree is named
|
|
155
|
-
* after its task, "does this directory exist" IS "am I resuming".
|
|
156
|
-
*
|
|
157
|
-
* The prune-and-retry is not paranoia: `git worktree add` refuses a path that
|
|
158
|
-
* is still REGISTERED even when the directory is gone (`flowviant clean` rm's
|
|
159
|
-
* the dirs, `git worktree list` keeps the stale entries), and that failure is
|
|
160
|
-
* permanent until pruned.
|
|
161
|
-
*/
|
|
162
|
-
export function ensureWorktree(repoRoot, wt, ref) {
|
|
163
|
-
// Resolve first. `existsSync` answers relative to THIS process's cwd while
|
|
164
|
-
// `git worktree add` answers relative to repoRoot, so a relative path makes
|
|
165
|
-
// the two disagree: the check says "not there", the add says "already
|
|
166
|
-
// exists", and the prune-and-retry can't fix a path that was never the one
|
|
167
|
-
// we looked at. Callers pass absolute paths today; this makes that not matter.
|
|
168
|
-
wt = resolve(wt);
|
|
169
|
-
if (existsSync(wt)) return { path: wt, fresh: false };
|
|
170
|
-
try {
|
|
171
|
-
git(['worktree', 'add', '--detach', wt, ref], repoRoot);
|
|
172
|
-
} catch {
|
|
173
|
-
git(['worktree', 'prune'], repoRoot);
|
|
174
|
-
git(['worktree', 'add', '--detach', wt, ref], repoRoot);
|
|
175
|
-
}
|
|
176
|
-
return { path: wt, fresh: true };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── WIP checkpoints: the sandbox's state, on the remote ────────────────────
|
|
180
|
-
//
|
|
181
|
-
// A task's uncommitted work used to exist in exactly one place — a directory on
|
|
182
|
-
// whichever machine claimed it. That made the checkout precious: losing the box
|
|
183
|
-
// lost the work, so a task was pinned to a host, the host had to be named in the
|
|
184
|
-
// UI, and a container could never be thrown away. Pushing the work somewhere
|
|
185
|
-
// durable inverts all of that. The sandbox becomes a cache.
|
|
186
|
-
//
|
|
187
|
-
// These snapshots go to `refs/flowviant-wip/<intentId>`, NOT to a branch: they
|
|
188
|
-
// are machine state, not history, and they must never appear in a PR, a branch
|
|
189
|
-
// listing, or anyone's `git log`. Force-pushed, because only the latest matters.
|
|
190
|
-
|
|
191
|
-
const wipRef = (intentId) => `refs/flowviant-wip/${intentId}`;
|
|
192
|
-
|
|
193
|
-
/** git, with extra environment — for GIT_INDEX_FILE and a committer identity we
|
|
194
|
-
* can't assume the machine has configured. */
|
|
195
|
-
function gitWithEnv(args, cwd, extraEnv) {
|
|
196
|
-
return execFileSync('git', args, {
|
|
197
|
-
cwd,
|
|
198
|
-
encoding: 'utf8',
|
|
199
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
200
|
-
env: { ...process.env, ...extraEnv },
|
|
201
|
-
}).trim();
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Snapshot everything in the worktree — staged, unstaged and untracked — and
|
|
206
|
-
* push it, WITHOUT touching the agent's HEAD, index or files.
|
|
207
|
-
*
|
|
208
|
-
* That constraint is why this doesn't just commit. The agent is a live process
|
|
209
|
-
* with its own git intentions; committing under it would rewrite state it is
|
|
210
|
-
* mid-way through reasoning about, and `git stash` would rip the files out from
|
|
211
|
-
* under an editor. Building the tree in a throwaway index leaves the agent's
|
|
212
|
-
* world untouched — it cannot tell this happened.
|
|
213
|
-
*
|
|
214
|
-
* Returns the commit sha, or null if there was nothing dirty / no remote.
|
|
215
|
-
*
|
|
216
|
-
* This PUSHES, so what it stages is a security boundary, not a detail: the
|
|
217
|
-
* daemon materializes plaintext env-vault secrets into this same worktree.
|
|
218
|
-
* They are gitignored (materializeInto refuses to write them otherwise), and
|
|
219
|
-
* `git add -A` honours .gitignore — but the whole point of the bug this guards
|
|
220
|
-
* against was an exclusion mechanism that silently did nothing, so the paths
|
|
221
|
-
* are ALSO subtracted by pathspec here. Two independent mechanisms, because one
|
|
222
|
-
* of them failing quietly is exactly what put secrets on a remote branch.
|
|
223
|
-
*/
|
|
224
|
-
export function checkpointWip(wt, intentId, baseRef) {
|
|
225
|
-
if (!isSafePathSegment(intentId)) return null;
|
|
226
|
-
const idx = join(tmpdir(), `flowviant-idx-${intentId}-${process.pid}`);
|
|
227
|
-
const env = {
|
|
228
|
-
GIT_INDEX_FILE: idx,
|
|
229
|
-
// A snapshot must never fail because the machine has no user.name — this is
|
|
230
|
-
// ours, not the user's, and it never lands in history anyone reads.
|
|
231
|
-
GIT_AUTHOR_NAME: 'Flowviant',
|
|
232
|
-
GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
|
|
233
|
-
GIT_COMMITTER_NAME: 'Flowviant',
|
|
234
|
-
GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
|
|
235
|
-
};
|
|
236
|
-
try {
|
|
237
|
-
const head = git(['rev-parse', 'HEAD'], wt);
|
|
238
|
-
gitWithEnv(['read-tree', head], wt, env);
|
|
239
|
-
gitWithEnv(
|
|
240
|
-
['add', '-A', '--', '.', ...materializedFiles(wt).map((p) => `:(exclude)${p}`)],
|
|
241
|
-
wt,
|
|
242
|
-
env
|
|
243
|
-
);
|
|
244
|
-
const tree = gitWithEnv(['write-tree'], wt, env);
|
|
245
|
-
// Nothing changed since HEAD — no snapshot worth pushing.
|
|
246
|
-
if (tree === git(['rev-parse', `${head}^{tree}`], wt)) return null;
|
|
247
|
-
const commit = gitWithEnv(
|
|
248
|
-
['commit-tree', tree, '-p', head, '-m', `flowviant wip ${intentId}`],
|
|
249
|
-
wt,
|
|
250
|
-
env
|
|
251
|
-
);
|
|
252
|
-
git(['push', '--force', 'origin', `${commit}:${wipRef(intentId)}`], wt);
|
|
253
|
-
return commit;
|
|
254
|
-
} catch {
|
|
255
|
-
// Offline, no push rights, a repo with no origin — a checkpoint is an
|
|
256
|
-
// optimisation, never a reason to fail a task.
|
|
257
|
-
return null;
|
|
258
|
-
} finally {
|
|
259
|
-
try {
|
|
260
|
-
rmSync(idx, { force: true });
|
|
261
|
-
} catch {
|
|
262
|
-
/* best-effort */
|
|
263
|
-
}
|
|
264
|
-
void baseRef;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Rebuild a worktree from its last pushed checkpoint. Returns true if one was
|
|
270
|
-
* found and applied.
|
|
271
|
-
*
|
|
272
|
-
* The reset is MIXED on purpose: it leaves the snapshot's content in the files
|
|
273
|
-
* with HEAD back at the parent, which is what the agent had before — dirty
|
|
274
|
-
* working tree, nothing staged it didn't stage itself. A soft reset would hand
|
|
275
|
-
* it a fully-staged index it never created.
|
|
276
|
-
*/
|
|
277
|
-
export function restoreWip(wt, intentId) {
|
|
278
|
-
if (!isSafePathSegment(intentId)) return false;
|
|
279
|
-
const ref = wipRef(intentId);
|
|
280
|
-
try {
|
|
281
|
-
git(['fetch', 'origin', `+${ref}:${ref}`], wt);
|
|
282
|
-
const commit = git(['rev-parse', ref], wt);
|
|
283
|
-
const parent = git(['rev-parse', `${commit}^`], wt);
|
|
284
|
-
git(['checkout', '--detach', commit], wt);
|
|
285
|
-
git(['reset', parent], wt);
|
|
286
|
-
return true;
|
|
287
|
-
} catch {
|
|
288
|
-
return false; // no checkpoint for this task, or it's unreachable
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Drop a task's checkpoint once its work has landed somewhere real. */
|
|
293
|
-
export function clearWip(wt, intentId) {
|
|
294
|
-
if (!isSafePathSegment(intentId)) return;
|
|
295
|
-
try {
|
|
296
|
-
git(['push', 'origin', '--delete', wipRef(intentId)], wt);
|
|
297
|
-
} catch {
|
|
298
|
-
/* already gone, or no remote */
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
142
|
export function resetWorktree(wt, baseRef) {
|
|
303
143
|
try {
|
|
304
144
|
git(['fetch', 'origin', '--quiet'], wt);
|
|
@@ -314,106 +154,3 @@ export function resetWorktree(wt, baseRef) {
|
|
|
314
154
|
}
|
|
315
155
|
}
|
|
316
156
|
|
|
317
|
-
/**
|
|
318
|
-
* What has changed in this worktree since `baseRef` — committed or not.
|
|
319
|
-
*
|
|
320
|
-
* The definition matters. `git diff --numstat <base>` (no `..HEAD`) compares the
|
|
321
|
-
* base against the WORKING TREE, so it covers commits the agent has made, staged
|
|
322
|
-
* work, and edits it has not committed yet. Anything narrower would go blank at
|
|
323
|
-
* the exact moments you look: right after a commit, or before the first one.
|
|
324
|
-
*
|
|
325
|
-
* Untracked files are added separately — they are invisible to `git diff` and
|
|
326
|
-
* are usually the most interesting thing an agent has done (a new module, a new
|
|
327
|
-
* test). Their line counts are read here rather than inferred; a file too large
|
|
328
|
-
* to be source is reported as a path with no counts instead of being read into
|
|
329
|
-
* memory.
|
|
330
|
-
*
|
|
331
|
-
* PATHS AND COUNTS ONLY. Nothing in here returns file content.
|
|
332
|
-
*
|
|
333
|
-
* Both git calls are `-z`, for the reason gitRaw exists: git's line-based output
|
|
334
|
-
* QUOTES any path that is not plain ASCII, so an accented filename arrives as
|
|
335
|
-
* "n\303\251w.txt" — a string that is not the path, cannot be stat'd, and reads
|
|
336
|
-
* as garbage in the tray. `-z` emits paths verbatim.
|
|
337
|
-
*/
|
|
338
|
-
export function worktreeDiffstat(cwd, baseRef, { maxFiles = 200 } = {}) {
|
|
339
|
-
const files = [];
|
|
340
|
-
let additions = 0;
|
|
341
|
-
let deletions = 0;
|
|
342
|
-
|
|
343
|
-
const add = (path, added, removed) => {
|
|
344
|
-
additions += added;
|
|
345
|
-
deletions += removed;
|
|
346
|
-
files.push({ path, added, removed });
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
try {
|
|
350
|
-
// `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
|
|
351
|
-
// but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
|
|
352
|
-
// then the new one. An empty path is therefore the rename marker, and the
|
|
353
|
-
// next two fields belong to it — read line-wise instead, a rename would
|
|
354
|
-
// report a file literally named "old => new".
|
|
355
|
-
const fields = splitNul(gitRaw(['diff', '--numstat', '-z', baseRef, '--'], cwd));
|
|
356
|
-
for (let i = 0; i < fields.length; i++) {
|
|
357
|
-
const [a, d, ...rest] = fields[i].split('\t');
|
|
358
|
-
let path = rest.join('\t');
|
|
359
|
-
if (!path) {
|
|
360
|
-
path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
|
|
361
|
-
i += 2;
|
|
362
|
-
if (!path) continue;
|
|
363
|
-
}
|
|
364
|
-
// Binary files report '-' for both counts; they changed, but not by lines.
|
|
365
|
-
add(path, a === '-' ? 0 : Number(a) || 0, d === '-' ? 0 : Number(d) || 0);
|
|
366
|
-
}
|
|
367
|
-
} catch {
|
|
368
|
-
// No base ref yet, or not a repo — nothing to report rather than a crash.
|
|
369
|
-
return null;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
try {
|
|
373
|
-
const untracked = splitNul(
|
|
374
|
-
gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], cwd)
|
|
375
|
-
);
|
|
376
|
-
for (const path of untracked) {
|
|
377
|
-
let added = 0;
|
|
378
|
-
// Past the cap this path will not be shown, so do not pay to read it.
|
|
379
|
-
// This is the one place the totals can undercount, and reaching it takes
|
|
380
|
-
// an untracked tree bigger than the list itself — a generated directory
|
|
381
|
-
// .gitignore missed. Statting and reading all of it on a 20s interval
|
|
382
|
-
// would block the roster poll and every other lane on this daemon.
|
|
383
|
-
if (files.length < maxFiles) {
|
|
384
|
-
try {
|
|
385
|
-
const st = statSync(join(cwd, path));
|
|
386
|
-
// Regular files ONLY. `git ls-files --others` will happily name a
|
|
387
|
-
// symlink or a fifo, and readFileSync on a fifo or a character device
|
|
388
|
-
// BLOCKS — on a 20s interval, on the daemon's single thread, that is
|
|
389
|
-
// the whole process wedged waiting for a device that may never write.
|
|
390
|
-
// 2 MB: past that it is a build artifact or a binary, and reading it
|
|
391
|
-
// to count newlines would be the most expensive thing this daemon does.
|
|
392
|
-
if (st.isFile() && st.size <= 2_000_000) {
|
|
393
|
-
const text = readFileSync(join(cwd, path), 'utf8');
|
|
394
|
-
// A NUL byte means binary. Counting "lines" in a PNG produces a
|
|
395
|
-
// number that is not wrong so much as meaningless, and it was being
|
|
396
|
-
// summed into the total shown beside git's real counts.
|
|
397
|
-
if (text.includes('\0')) throw new Error('binary');
|
|
398
|
-
// Lines, not segments. A file ending in a newline — i.e. essentially
|
|
399
|
-
// every source file an agent writes — splits into one more piece
|
|
400
|
-
// than it has lines, and that +1 was landing in the totals shown
|
|
401
|
-
// beside git's own counts.
|
|
402
|
-
added = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
|
|
403
|
-
}
|
|
404
|
-
} catch {
|
|
405
|
-
/* vanished between listing and reading — report the path, no counts */
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
add(path, added, 0);
|
|
409
|
-
}
|
|
410
|
-
} catch {
|
|
411
|
-
/* untracked listing failed — the tracked half still stands */
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (files.length === 0) return null;
|
|
415
|
-
// Totals stay whole while the LIST is capped: a truncated list must never
|
|
416
|
-
// quietly shrink the number printed beside it.
|
|
417
|
-
const truncated = Math.max(0, files.length - maxFiles);
|
|
418
|
-
return { files: files.slice(0, maxFiles), additions, deletions, truncated };
|
|
419
|
-
}
|
package/bin/lib/grant.mjs
CHANGED
|
@@ -171,12 +171,33 @@ export function safePathname(url) {
|
|
|
171
171
|
* `//evil.com` and `/\evil.com` are protocol-relative to a browser, so a naive
|
|
172
172
|
* "starts with /" check turns the callback into an open redirect on the tunnel
|
|
173
173
|
* origin. Anything that is not a single-slash relative path becomes '/'.
|
|
174
|
+
*
|
|
175
|
+
* STRUCTURAL, NOT LEXICAL, and that distinction is the whole fix. This used to
|
|
176
|
+
* be `/^\/(?![/\\])/` plus a CR/LF check — and an audit walked through it with
|
|
177
|
+
* a TAB: browsers STRIP tab, CR and LF out of a URL before parsing, so
|
|
178
|
+
* `"/\t/evil.com"` passed the guard, was written verbatim into Location by the
|
|
179
|
+
* callback, and navigated to `//evil.com`. Parsing against a dummy base and
|
|
180
|
+
* re-serialising reproduces exactly what the browser will do — there is no
|
|
181
|
+
* second implementation of URL parsing here to drift, and the value returned
|
|
182
|
+
* is the one the parser produced. The lexical checks stay in front of it as
|
|
183
|
+
* the cheap first line; the origin comparison is the guarantee. Ported from
|
|
184
|
+
* the server twins (previewAuthorize.routes.ts, preview-relay util.ts), which
|
|
185
|
+
* closed the same hole the same way.
|
|
174
186
|
*/
|
|
175
187
|
export function safeRelative(raw) {
|
|
176
188
|
if (!raw) return '/';
|
|
177
189
|
const s = String(raw);
|
|
178
190
|
if (s.length > 512) return '/';
|
|
179
|
-
if (/
|
|
180
|
-
if (
|
|
181
|
-
|
|
191
|
+
if (!s.startsWith('/')) return '/';
|
|
192
|
+
if (/^\/[/\\]/.test(s)) return '/';
|
|
193
|
+
// Every C0 control, space and DEL — not just CR/LF.
|
|
194
|
+
// eslint-disable-next-line no-control-regex
|
|
195
|
+
if (/[\x00-\x20\x7f]/.test(s)) return '/';
|
|
196
|
+
try {
|
|
197
|
+
const u = new URL(s, 'https://x.invalid');
|
|
198
|
+
if (u.origin !== 'https://x.invalid') return '/';
|
|
199
|
+
return u.pathname + u.search + u.hash;
|
|
200
|
+
} catch {
|
|
201
|
+
return '/';
|
|
202
|
+
}
|
|
182
203
|
}
|
package/bin/lib/landed.mjs
CHANGED
|
@@ -23,18 +23,19 @@
|
|
|
23
23
|
* report nothing — ignorance is never turned into a state.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
26
27
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
27
28
|
import { join } from 'node:path';
|
|
28
29
|
import { homedir } from 'node:os';
|
|
29
30
|
import { createHash } from 'node:crypto';
|
|
30
31
|
import { git, baseBranchName } from './git.mjs';
|
|
31
|
-
import { taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
32
|
+
import { stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
32
33
|
import { warn } from './ui.mjs';
|
|
33
34
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
34
35
|
|
|
35
36
|
const LANDED_URL = FLEET_URL.replace(/\/agents\/?$/, '/base-landed');
|
|
36
37
|
/** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
|
|
37
|
-
* batches: the persisted tip advances to the last commit actually
|
|
38
|
+
* batches: the persisted tip advances to the last commit actually walked,
|
|
38
39
|
* so the remainder is picked up on the next beat rather than skipped forever
|
|
39
40
|
* — a trailered card in commit 51 of a big catch-up still closes. */
|
|
40
41
|
const MAX_COMMITS = 50;
|
|
@@ -74,27 +75,64 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
74
75
|
}
|
|
75
76
|
};
|
|
76
77
|
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
78
|
+
/** git with the buffer the WALK needs. git.mjs's call takes execFileSync's
|
|
79
|
+
* default 1MiB cap, and a catch-up range's `%B` bodies blew through it —
|
|
80
|
+
* the throw landed in the reseed catch below, which skipped the whole range
|
|
81
|
+
* and silently lost every trailer in it. 8MB is repoState's number for the
|
|
82
|
+
* same reason; rev-list output at 41 bytes a commit clears ~200k commits
|
|
83
|
+
* before it matters. */
|
|
84
|
+
const gitWide = (args) =>
|
|
85
|
+
execFileSync('git', args, {
|
|
86
|
+
cwd: repoRoot,
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
90
|
+
}).trim();
|
|
91
|
+
|
|
92
|
+
/** New non-merge commits in from..to, OLDEST FIRST — the next batch of at
|
|
93
|
+
* most MAX_COMMITS, plus the tip the state should advance to when the range
|
|
94
|
+
* held more. `--no-merges` for the same reason branchCommits keeps it: a
|
|
95
|
+
* merge commit describes a range rather than doing work, and its
|
|
96
|
+
* constituents are walked as themselves.
|
|
97
|
+
*
|
|
98
|
+
* THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG. Git
|
|
99
|
+
* preserves the 0x1e/0x1f delimiter bytes inside a commit BODY (verified
|
|
100
|
+
* empirically), so a crafted message can fabricate whole records — an
|
|
101
|
+
* arbitrary sha plus Flowviant-Task ids that /fleet/base-landed would close
|
|
102
|
+
* cards on. rev-list prints nothing an author controls, so its output is
|
|
103
|
+
* the set of commits that exist: a parsed record whose sha is not in the
|
|
104
|
+
* batch is a forgery and is dropped, a repeated sha is the same forgery
|
|
105
|
+
* wearing a real commit's name, and the delimiter bytes are stripped from
|
|
106
|
+
* every surviving field.
|
|
107
|
+
*
|
|
108
|
+
* BOUNDING THE BODY FETCH TO THE BATCH is what makes the header's batching
|
|
109
|
+
* contract true at any range size: `%B` over the whole range grows without
|
|
110
|
+
* bound, so the formatted log runs over exactly the shas being reported
|
|
111
|
+
* this beat (`--no-walk=unsorted` shows precisely the commits named, in
|
|
112
|
+
* argv order — measured). */
|
|
80
113
|
const walk = (from, to) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
114
|
+
const shas = gitWide(['rev-list', '--reverse', '--no-merges', `${from}..${to}`])
|
|
115
|
+
.split('\n')
|
|
116
|
+
.filter((s) => SHA_RE.test(s));
|
|
117
|
+
const batch = shas.slice(0, MAX_COMMITS);
|
|
118
|
+
const tipAfter = shas.length > MAX_COMMITS ? batch[batch.length - 1] : null;
|
|
119
|
+
if (batch.length === 0) return { commits: [], tipAfter };
|
|
120
|
+
const real = new Set(batch);
|
|
121
|
+
const raw = gitWide(['log', '--no-walk=unsorted', '--format=%H%x1f%s%x1f%B%x1e', ...batch]);
|
|
85
122
|
const out = [];
|
|
86
123
|
for (const rec of raw.split('\x1e')) {
|
|
87
124
|
const line = rec.replace(/^\n+/, '');
|
|
88
125
|
if (!line.trim()) continue;
|
|
89
|
-
const [sha, subject,
|
|
90
|
-
if (!
|
|
126
|
+
const [sha, subject, ...bodyParts] = line.split('\x1f');
|
|
127
|
+
if (!real.has(sha)) continue;
|
|
128
|
+
real.delete(sha);
|
|
91
129
|
out.push({
|
|
92
130
|
sha,
|
|
93
|
-
subject:
|
|
94
|
-
taskIds: taskIdsFromMessage(
|
|
131
|
+
subject: stripDelims(subject).slice(0, 200),
|
|
132
|
+
taskIds: taskIdsFromMessage(stripDelims(bodyParts.join('\n'))).slice(0, 8),
|
|
95
133
|
});
|
|
96
134
|
}
|
|
97
|
-
return out;
|
|
135
|
+
return { commits: out, tipAfter };
|
|
98
136
|
};
|
|
99
137
|
|
|
100
138
|
/** Look at the base tip; if it moved, report the range. Call after anything
|
|
@@ -112,21 +150,30 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
112
150
|
return;
|
|
113
151
|
}
|
|
114
152
|
if (st.tip === tip) return;
|
|
115
|
-
let
|
|
153
|
+
let walked;
|
|
116
154
|
try {
|
|
117
|
-
|
|
155
|
+
walked = walk(st.tip, ref);
|
|
118
156
|
} catch {
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
157
|
+
// Two failures land here and only one may reseed. Probe the range
|
|
158
|
+
// directly: if rev-list cannot COUNT it, the old tip is genuinely gone
|
|
159
|
+
// (force-push, gc) and observation reseeds at the new one — ignorance is
|
|
160
|
+
// never turned into a state. Anything else (a transient spawn failure,
|
|
161
|
+
// an over-buffer) keeps the stored tip so the next beat retries the same
|
|
162
|
+
// range; reseeding on those was what skipped a whole catch-up range and
|
|
163
|
+
// permanently lost every trailer in it.
|
|
164
|
+
try {
|
|
165
|
+
git(['rev-list', '--count', `${st.tip}..${ref}`], repoRoot);
|
|
166
|
+
} catch {
|
|
167
|
+
writeState({ ref, tip });
|
|
168
|
+
}
|
|
122
169
|
return;
|
|
123
170
|
}
|
|
124
171
|
// Oldest-first BATCH: a range past the server's cap advances the tip only
|
|
125
|
-
// to the last commit
|
|
172
|
+
// to the last commit walked, so the remainder rides the next beat —
|
|
126
173
|
// nothing is skipped forever. (A range of nothing but merge commits still
|
|
127
174
|
// reports, tip-only: the tip moving is the fact deploy-on-merge rides.)
|
|
128
|
-
const commits =
|
|
129
|
-
const reportedTip =
|
|
175
|
+
const commits = walked.commits;
|
|
176
|
+
const reportedTip = walked.tipAfter ?? tip;
|
|
130
177
|
inFlight = true;
|
|
131
178
|
try {
|
|
132
179
|
const res = await fetch(LANDED_URL, {
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -367,14 +367,17 @@ const TAIL_BYTES = 2000;
|
|
|
367
367
|
* origin with 502, so without this the product would report "live" over a 502 —
|
|
368
368
|
* Flowviant asserting a state it never measured.
|
|
369
369
|
*
|
|
370
|
-
* `stillServing` (optional, async → boolean) is the ATTRIBUTION
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
370
|
+
* `stillServing` (optional, async → boolean) is the ATTRIBUTION check — the
|
|
371
|
+
* same `listenersIn(worktree)` predicate the caller ran at the boundary —
|
|
372
|
+
* and it guards EVERY gate in here, not just the probe: the open-time
|
|
373
|
+
* re-validation, one more look immediately before cloudflared spawns, and the
|
|
374
|
+
* recurring probe. Ports are global to a box and a worktree is not: when the
|
|
375
|
+
* driver's dev server dies and anything else — a teammate's worktree, a
|
|
376
|
+
* database — binds the same number, a bare `isListening` answers yes and the
|
|
377
|
+
* URL+password serve the NEW process, outside every consent gate. Three gates
|
|
378
|
+
* on one predicate, so "the origin is alive" always means "THIS session's
|
|
379
|
+
* origin"; a bare TCP connect stands in only when no predicate was given (an
|
|
380
|
+
* older caller).
|
|
378
381
|
*
|
|
379
382
|
* `onAbuse` fires when the gate closes itself after repeated failed password
|
|
380
383
|
* attempts — AFTER the share is torn down locally — so the caller can report
|
|
@@ -400,10 +403,21 @@ export async function openTunnel({
|
|
|
400
403
|
shareId,
|
|
401
404
|
authorizeUrl,
|
|
402
405
|
}) {
|
|
406
|
+
// ONE predicate for every liveness question this function asks. Attribution
|
|
407
|
+
// when the caller gave it, a bare TCP connect only when it did not; an
|
|
408
|
+
// attribution check that errors is not a "yes".
|
|
409
|
+
const serving = async () => {
|
|
410
|
+
try {
|
|
411
|
+
return stillServing ? await stillServing() : await isListening(port);
|
|
412
|
+
} catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
403
417
|
// Re-validate at the machine. The server checked this port against the last
|
|
404
418
|
// report; reports are up to a minute old and a dev server is a process a
|
|
405
419
|
// human can stop at any moment.
|
|
406
|
-
if (!(await
|
|
420
|
+
if (!(await serving())) {
|
|
407
421
|
return { error: `nothing is listening on port ${port} in this worktree any more.` };
|
|
408
422
|
}
|
|
409
423
|
|
|
@@ -464,6 +478,17 @@ export async function openTunnel({
|
|
|
464
478
|
return { error: 'could not start the password gate for this preview, so nothing was published.' };
|
|
465
479
|
}
|
|
466
480
|
|
|
481
|
+
// The last look BEFORE anything becomes public. Between the check above and
|
|
482
|
+
// here sit a possible cloudflared download and the gate's own bind — long
|
|
483
|
+
// enough for the dev server to die and an unrelated process to take the
|
|
484
|
+
// port, which a check that ran only at the top would never see again until
|
|
485
|
+
// the probe's first beat, up to probeMs later. Same predicate, so the moment
|
|
486
|
+
// the hostname exists it can only be pointing at THIS session's origin.
|
|
487
|
+
if (!(await serving())) {
|
|
488
|
+
stop();
|
|
489
|
+
return { error: `nothing is listening on port ${port} in this worktree any more.` };
|
|
490
|
+
}
|
|
491
|
+
|
|
467
492
|
const args = ['tunnel', '--url', `http://localhost:${gate.port}`];
|
|
468
493
|
// Send the origin the Host it expects. Vite and Next reject a Host they do
|
|
469
494
|
// not recognise, so without this the tunnel resolves and then 403s.
|
|
@@ -513,13 +538,7 @@ export async function openTunnel({
|
|
|
513
538
|
// share would keep serving a process nobody consented to publish.
|
|
514
539
|
probe = setInterval(async () => {
|
|
515
540
|
if (stopped) return;
|
|
516
|
-
|
|
517
|
-
try {
|
|
518
|
-
serving = stillServing ? await stillServing() : await isListening(port);
|
|
519
|
-
} catch {
|
|
520
|
-
serving = false; // an attribution check that errors is not a "yes"
|
|
521
|
-
}
|
|
522
|
-
if (!serving) {
|
|
541
|
+
if (!(await serving())) {
|
|
523
542
|
const dead = onDead;
|
|
524
543
|
stop();
|
|
525
544
|
try {
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -298,21 +298,16 @@ rules:
|
|
|
298
298
|
it down: file_card the slice you are starting, raise_card the rest so the
|
|
299
299
|
queue holds the plan instead of your context.
|
|
300
300
|
FILL IN THE SHAPE when you do — \`points\`, \`acceptanceCriteria\` ("done
|
|
301
|
-
when", one line each), \`codeAnchors\` (the modules the card owns)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
only moment anyone knows those answers.
|
|
307
|
-
NAME THE FEATURE. When one ask becomes several cards, give them all the same
|
|
308
|
-
\`featureName\` — a short name a human would recognise ("Password reset",
|
|
309
|
-
"Billing export"). That is what lets the Board show them as one piece of work
|
|
310
|
-
instead of five loose rows. Reuse a name already on the board rather than
|
|
311
|
-
coining a synonym for it.
|
|
301
|
+
when", one line each), and \`codeAnchors\` (the modules the card owns). This
|
|
302
|
+
is not bookkeeping: points are how an agent's workload is budgeted when
|
|
303
|
+
cards are deployed, the ship review quiz is generated from the criteria,
|
|
304
|
+
and the anchors are what the planner reads. Leave them empty and nothing
|
|
305
|
+
breaks — the review just has less to ask about. A card you have just
|
|
306
|
+
designed is the only moment anyone knows those answers.
|
|
312
307
|
10. YOU CAN CORRECT A CARD YOU ALREADY FILED. update_cards patches the SHAPE of
|
|
313
|
-
cards that exist — \`points
|
|
314
|
-
|
|
315
|
-
|
|
308
|
+
cards that exist — \`points\` and \`waitsOn\` — up to 25 in one call. This is
|
|
309
|
+
the tool for "help me plan the backlog": list_cards, decide, then send
|
|
310
|
+
every change in ONE call. It cannot move a card, close one, assign
|
|
316
311
|
anyone or touch a receipt — organising a backlog is not working on it, so do
|
|
317
312
|
not log_work or deliver anything you have not actually built. A card that is already delivered is refused, because its
|
|
318
313
|
spec is what somebody's review is about. And when list_cards says
|
|
@@ -321,9 +316,11 @@ rules:
|
|
|
321
316
|
SAY WHAT WAITS ON WHAT. \`waitsOn\` takes the task ids a card cannot start
|
|
322
317
|
until, and it is what turns a feature from a heap into a sequence: the
|
|
323
318
|
migration before the endpoint, the endpoint before the UI, the polish last.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
319
|
+
When cards are deployed to agents, the split respects it — an agent holding
|
|
320
|
+
a card's prerequisite merges before the dependent one starts — and the task
|
|
321
|
+
page shows it, so a person who was not in this conversation can still see
|
|
322
|
+
where to start. Declare it while you are decomposing, because that is the
|
|
323
|
+
one moment anyone knows.
|
|
327
324
|
11. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
|
|
328
325
|
one-paragraph summary and the commit shas. Delivered is ASSERTED; done is
|
|
329
326
|
OBSERVED (the merge, on their word). Never claim done, and never deliver
|
|
@@ -483,8 +480,8 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
|
|
|
483
480
|
|
|
484
481
|
|
|
485
482
|
/**
|
|
486
|
-
*
|
|
487
|
-
*
|
|
483
|
+
* EVERY SERVER-CARRIED STRING HERE IS FENCED — the feature name, the file
|
|
484
|
+
* list, and the predicted-page list.
|
|
488
485
|
*
|
|
489
486
|
* A card title is member-authored, and worse: ticket triage falls back to the
|
|
490
487
|
* REPORTER's ticket title verbatim, so a stranger can put words in it. That
|
|
@@ -496,9 +493,11 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
|
|
|
496
493
|
* reset is the backstop, and it only cleans the wiki worktree. Anything written
|
|
497
494
|
* outside it survives.
|
|
498
495
|
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
496
|
+
* The predicted pages come off the roster exactly as the title does — a
|
|
497
|
+
* planner wrote them from card text, and card text is member-authored — so an
|
|
498
|
+
* unfenced `- <page>` line was the same injection lane with a different field
|
|
499
|
+
* name. The file list is fenced for the same reason at lower stakes: a path is
|
|
500
|
+
* attacker-influenceable too, and there is no cost to it.
|
|
502
501
|
*/
|
|
503
502
|
export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages = [] }) =>
|
|
504
503
|
`A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
|
|
@@ -510,10 +509,10 @@ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages =
|
|
|
510
509
|
// misses a page whose file list has drifted or that documents a CONCEPT rather
|
|
511
510
|
// than a directory. This is a hint to CHECK, never a list to trust.
|
|
512
511
|
(predictedPages.length
|
|
513
|
-
? `When this work was planned,
|
|
514
|
-
`Treat
|
|
515
|
-
`editing, and ignore any that turned out to be unaffected:\n` +
|
|
516
|
-
`${predictedPages.map((p) => `- ${p}`).join('\n')}\n\n`
|
|
512
|
+
? `When this work was planned, the vault pages listed below were expected\n` +
|
|
513
|
+
`to go stale. Treat the list as a lead, not a fact — verify each against\n` +
|
|
514
|
+
`the code before editing, and ignore any that turned out to be unaffected:\n` +
|
|
515
|
+
`${fence('PREDICTED PAGES', predictedPages.map((p) => `- ${p}`).join('\n'))}\n\n`
|
|
517
516
|
: '') +
|
|
518
517
|
`Follow your instructions: update the touched vault pages (and any docs/\n` +
|
|
519
518
|
`chapter that covers them), append the feature-history entry to log.md,\n` +
|