@vibe-commit/capture 0.7.0 → 0.9.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 +238 -7
- package/dist/account_not_here.d.ts +15 -0
- package/dist/account_not_here.js +25 -0
- package/dist/account_not_here.js.map +1 -0
- package/dist/agents/claude_code.js +13 -0
- package/dist/agents/claude_code.js.map +1 -1
- package/dist/agents/codex_cli.js +11 -0
- package/dist/agents/codex_cli.js.map +1 -1
- package/dist/agents/cursor.js +7 -1
- package/dist/agents/cursor.js.map +1 -1
- package/dist/agents/registry.d.ts +7 -0
- package/dist/agents/registry.js +9 -0
- package/dist/agents/registry.js.map +1 -1
- package/dist/agents/types.d.ts +32 -0
- package/dist/agents/types.js.map +1 -1
- package/dist/bounded_id.d.ts +29 -0
- package/dist/bounded_id.js +42 -0
- package/dist/bounded_id.js.map +1 -0
- package/dist/codex_folders.d.ts +340 -0
- package/dist/codex_folders.js +1448 -0
- package/dist/codex_folders.js.map +1 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +21 -0
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/backfill.d.ts +29 -7
- package/dist/commands/backfill.js +387 -109
- package/dist/commands/backfill.js.map +1 -1
- package/dist/commands/browser_credential.d.ts +4 -2
- package/dist/commands/browser_credential.js +5 -2
- package/dist/commands/browser_credential.js.map +1 -1
- package/dist/commands/connect.d.ts +10 -1
- package/dist/commands/connect.js +14 -9
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connect_account.d.ts +135 -0
- package/dist/commands/connect_account.js +321 -0
- package/dist/commands/connect_account.js.map +1 -0
- package/dist/commands/connect_picker.js +204 -29
- package/dist/commands/connect_picker.js.map +1 -1
- package/dist/commands/device_credential.d.ts +3 -1
- package/dist/commands/device_credential.js +1 -0
- package/dist/commands/device_credential.js.map +1 -1
- package/dist/commands/finalize.d.ts +67 -0
- package/dist/commands/finalize.js +450 -114
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/finish_mint.d.ts +28 -0
- package/dist/commands/finish_mint.js +16 -4
- package/dist/commands/finish_mint.js.map +1 -1
- package/dist/commands/scan.d.ts +28 -0
- package/dist/commands/scan.js +11 -0
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/status.d.ts +19 -1
- package/dist/commands/status.js +123 -14
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status_machine.d.ts +12 -3
- package/dist/commands/status_machine.js +111 -13
- package/dist/commands/status_machine.js.map +1 -1
- package/dist/commands/sync_reason.d.ts +17 -0
- package/dist/commands/sync_reason.js +25 -0
- package/dist/commands/sync_reason.js.map +1 -0
- package/dist/consent.d.ts +131 -3
- package/dist/consent.js +248 -14
- package/dist/consent.js.map +1 -1
- package/dist/copy/strings.d.ts +165 -4
- package/dist/copy/strings.js +190 -7
- package/dist/copy/strings.js.map +1 -1
- package/dist/folder_grants.d.ts +44 -0
- package/dist/folder_grants.js +101 -0
- package/dist/folder_grants.js.map +1 -0
- package/dist/git.d.ts +26 -4
- package/dist/git.js +82 -11
- package/dist/git.js.map +1 -1
- package/dist/grants_sync.d.ts +165 -0
- package/dist/grants_sync.js +289 -0
- package/dist/grants_sync.js.map +1 -0
- package/dist/grants_verb.d.ts +70 -0
- package/dist/grants_verb.js +121 -0
- package/dist/grants_verb.js.map +1 -0
- package/dist/hooks/entry.d.ts +30 -50
- package/dist/hooks/entry.js +601 -561
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/fire_lock.d.ts +31 -0
- package/dist/hooks/fire_lock.js +120 -12
- package/dist/hooks/fire_lock.js.map +1 -1
- package/dist/hooks/session_grants.d.ts +59 -0
- package/dist/hooks/session_grants.js +91 -0
- package/dist/hooks/session_grants.js.map +1 -0
- package/dist/hooks/session_start.d.ts +28 -7
- package/dist/hooks/session_start.js +55 -11
- package/dist/hooks/session_start.js.map +1 -1
- package/dist/index.js +22 -5
- package/dist/index.js.map +1 -1
- package/dist/machine_state.d.ts +44 -1
- package/dist/machine_state.js +75 -12
- package/dist/machine_state.js.map +1 -1
- package/dist/oauth/discovery.js +2 -1
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/oauth/pkce.js +2 -1
- package/dist/oauth/pkce.js.map +1 -1
- package/dist/oauth/retire.d.ts +10 -0
- package/dist/oauth/retire.js +13 -0
- package/dist/oauth/retire.js.map +1 -1
- package/dist/paths.d.ts +54 -1
- package/dist/paths.js +88 -12
- package/dist/paths.js.map +1 -1
- package/dist/pending_revoke.d.ts +117 -0
- package/dist/pending_revoke.js +233 -0
- package/dist/pending_revoke.js.map +1 -0
- package/dist/pending_revoke_file.d.ts +55 -0
- package/dist/pending_revoke_file.js +74 -0
- package/dist/pending_revoke_file.js.map +1 -0
- package/dist/physical_path.d.ts +46 -0
- package/dist/physical_path.js +151 -0
- package/dist/physical_path.js.map +1 -0
- package/dist/picker.d.ts +82 -5
- package/dist/picker.js +183 -10
- package/dist/picker.js.map +1 -1
- package/dist/picker_checklist.d.ts +27 -3
- package/dist/picker_checklist.js +81 -6
- package/dist/picker_checklist.js.map +1 -1
- package/dist/policy.d.ts +24 -1
- package/dist/policy.js +30 -2
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +55 -2
- package/dist/post.js +72 -22
- package/dist/post.js.map +1 -1
- package/dist/project.d.ts +89 -0
- package/dist/project.js +123 -0
- package/dist/project.js.map +1 -1
- package/dist/redact.d.ts +77 -2
- package/dist/redact.js +713 -47
- package/dist/redact.js.map +1 -1
- package/dist/session_targets.d.ts +713 -0
- package/dist/session_targets.js +2602 -0
- package/dist/session_targets.js.map +1 -0
- package/dist/spool.d.ts +12 -0
- package/dist/spool.js +29 -1
- package/dist/spool.js.map +1 -1
- package/dist/spool_union.d.ts +51 -0
- package/dist/spool_union.js +57 -0
- package/dist/spool_union.js.map +1 -0
- package/dist/state.d.ts +5 -0
- package/dist/state.js +19 -1
- package/dist/state.js.map +1 -1
- package/dist/system_message.d.ts +25 -2
- package/dist/system_message.js +29 -5
- package/dist/system_message.js.map +1 -1
- package/dist/text_tail.d.ts +8 -0
- package/dist/text_tail.js +19 -0
- package/dist/text_tail.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -31,24 +31,26 @@
|
|
|
31
31
|
* the offer open no transcript, and nothing else is read from this one. Codex needs no check (its index already holds the
|
|
32
32
|
* real `cwd`). Cursor records no folder at all (measured, STATIC), so its sessions are never uploaded and are counted instead.
|
|
33
33
|
*/
|
|
34
|
-
import {
|
|
34
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
35
35
|
import { basename, isAbsolute } from "node:path";
|
|
36
|
-
import { admitsTranscript, DIALECTS } from "../agents/registry.js";
|
|
36
|
+
import { admitsTranscript, agentIsolatesRecords, DIALECTS } from "../agents/registry.js";
|
|
37
37
|
import { BACKFILL, COMMANDS, SCAN, URLS, shortDate } from "../copy/index.js";
|
|
38
|
-
import { grantsUnreadable, isCaptureAllowed } from "../consent.js";
|
|
38
|
+
import { captureStanding, grantsUnreadable, hasAnyConsent, isCaptureAllowed } from "../consent.js";
|
|
39
|
+
import { folderStandingOf } from "../folder_grants.js";
|
|
39
40
|
import { loadCredential } from "../credential.js";
|
|
40
41
|
import { EXIT } from "../exit.js";
|
|
41
|
-
import { firstReflogSeconds,
|
|
42
|
-
import {
|
|
42
|
+
import { firstReflogSeconds, onceStrictRepoSlug } from "../git.js";
|
|
43
|
+
import { FOLDER_REPOSITORY_FORBIDDEN } from "../policy.js";
|
|
43
44
|
import { CAPTURE_NOT_APPROVED, ingestUrl } from "../post.js";
|
|
44
45
|
import { resolveProjectKeys } from "../project.js";
|
|
46
|
+
import { TOP_LEVEL_CWD_ONLY, findSubagentTranscriptsChecked, groupKey, loadTargetRecord, scanTranscriptKeys, stopKeyOf, stopTarget } from "../session_targets.js";
|
|
45
47
|
import { SESSION_LIVE_WINDOW_MS } from "../spool.js";
|
|
46
48
|
import { fileState, loadSessionState } from "../state.js";
|
|
47
49
|
import { glyph, paint, renderErrorBlock, tildePath, truncateMiddle, truncatePath, wrap, WRAP_COLUMNS } from "../term.js";
|
|
48
50
|
import { writeLines } from "./context.js";
|
|
49
51
|
import { credentialProblem } from "./credential_problem.js";
|
|
50
52
|
import { authCommandFor, channelCommand, connectCommandFor } from "../install.js";
|
|
51
|
-
import { closeSession, ingestUrlUnavailableBlock } from "./finalize.js";
|
|
53
|
+
import { closeSession, closeSpecOf, ingestUrlUnavailableBlock, resolveSessionTargets } from "./finalize.js";
|
|
52
54
|
import { scanMachine } from "./scan.js";
|
|
53
55
|
import { renderVerbHelp } from "../verb_help.js";
|
|
54
56
|
/** A transcript larger than this on disk is not offered (the 50 MiB policy cap). Sub-agent streams are not capped. */
|
|
@@ -155,14 +157,150 @@ export function planBackfill(ctx, scanFn = scanMachine) {
|
|
|
155
157
|
repos.push({ slug: repo.slug, candidates });
|
|
156
158
|
total += candidates.length;
|
|
157
159
|
}
|
|
160
|
+
const parent = planParentSessions(ctx, scanned);
|
|
161
|
+
total += parent.length;
|
|
158
162
|
return {
|
|
159
163
|
repos,
|
|
164
|
+
parent,
|
|
160
165
|
total,
|
|
161
166
|
cursorSessions,
|
|
162
167
|
overSize,
|
|
163
168
|
noReflog: [...noReflog.entries()].map(([path, sessions]) => ({ path, sessions })).sort((a, b) => (a.path < b.path ? -1 : 1)),
|
|
164
169
|
};
|
|
165
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* The sessions run from folders outside every repository, minus those already fully delivered. ⛔ Opens no transcript. Offered only
|
|
173
|
+
* where this machine consents to anything at all. Only a dialect that can isolate records (Claude Code and Codex; see `isolatesContentRecords`):
|
|
174
|
+
* Cursor records no folder, so it is not offered. A session whose target
|
|
175
|
+
* record says it is settled with every target's ledger at its end is done; one that touched nothing chosen is re-checked each run
|
|
176
|
+
* (cheap, and a later grant may change the answer), which the offer says plainly.
|
|
177
|
+
*/
|
|
178
|
+
function planParentSessions(ctx, scanned) {
|
|
179
|
+
const nowMs = (ctx.now ?? (() => new Date()))().getTime();
|
|
180
|
+
if (!hasAnyConsent(ctx.home))
|
|
181
|
+
return [];
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const folder of scanned.folders ?? []) {
|
|
184
|
+
for (const member of folder.members ?? []) {
|
|
185
|
+
// ⛔ C28: only a dialect that can isolate records (`isolatesContentRecords`) is offered here: a parent-folder session is judged by the
|
|
186
|
+
// repos its records name, and a dialect that cannot keep one repo's copy free of another's would send every repo it named the
|
|
187
|
+
// others' prompts and output. Such a session is uploaded, if at all, by the in-repo path, to the repo it ended in.
|
|
188
|
+
if (!agentIsolatesRecords(member.agent))
|
|
189
|
+
continue;
|
|
190
|
+
const found = statFile(member.session.path);
|
|
191
|
+
if (found === null || found.size > MAX_SESSION_BYTES)
|
|
192
|
+
continue;
|
|
193
|
+
const sessionId = sessionIdOf(member.agent, member.session.path);
|
|
194
|
+
if (sessionId === null)
|
|
195
|
+
continue;
|
|
196
|
+
if (parentDone(ctx.home, ctx.env, sessionId, member.session.path, found.size, nowMs - found.mtimeMs >= SESSION_LIVE_WINDOW_MS))
|
|
197
|
+
continue;
|
|
198
|
+
out.push({
|
|
199
|
+
agent: member.agent,
|
|
200
|
+
sessionId,
|
|
201
|
+
path: member.session.path,
|
|
202
|
+
dir: folder.path,
|
|
203
|
+
mtimeMs: found.mtimeMs,
|
|
204
|
+
startedMs: member.session.startedMs ?? member.session.mtimeMs,
|
|
205
|
+
size: found.size,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out.sort((a, b) => a.mtimeMs - b.mtimeMs || (a.path < b.path ? -1 : 1));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Has this target's ledger sent EVERY stream of the session to its end (C29 ii)? The main stream to `mainEof` and each delegated file to
|
|
213
|
+
* its own size, each capped at `sizeCap[fileKey]` when the target is a folder's (a folder never sends past what was scanned).
|
|
214
|
+
*/
|
|
215
|
+
function streamsSentTo(state, mainEof, subs, sizeCap) {
|
|
216
|
+
if (fileState(state, "main").sentOffset < mainEof)
|
|
217
|
+
return false;
|
|
218
|
+
for (const sub of subs) {
|
|
219
|
+
const to = sizeCap === undefined ? sub.size : Math.min(sub.size, sizeCap[sub.fileKey] ?? 0);
|
|
220
|
+
if (fileState(state, sub.fileKey).sentOffset < to)
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Nothing a backfill could add for this session, under TODAY's consent and the file's CURRENT size (C18 #9, #10). Needs no transcript
|
|
227
|
+
* read: it is decided from the session's target record and its ledgers.
|
|
228
|
+
*
|
|
229
|
+
* - discovery is settled (the whole transcript was read and no root is waiting);
|
|
230
|
+
* - every repo root the record names that is GRANTED NOW has a target whose ledger has sent to the current end of the file and
|
|
231
|
+
* holds nothing back. A repo granted after the session was last uploaded has no target yet, so the session is offered again,
|
|
232
|
+
* and the run then uploads only the repos that still owe bytes;
|
|
233
|
+
* - the folder decision still holds: a folder target is done only when the scan reached the current end AND its ledger sent that
|
|
234
|
+
* far (bytes appended since are offered, not skipped for ever), and a session that named no repository at all whose launch
|
|
235
|
+
* folder is granted now but has no folder target yet is not done.
|
|
236
|
+
*/
|
|
237
|
+
function parentDone(home, env, sessionId, transcriptPath, size, quiet) {
|
|
238
|
+
const record = loadTargetRecord(home, sessionId);
|
|
239
|
+
if (record === null || !record.settled)
|
|
240
|
+
return false;
|
|
241
|
+
// ⛔ C29 (i): THE SCAN MUST HAVE SEEN EVERY STREAM AS IT IS NOW. `settled` says the streams that were scanned were read to their end;
|
|
242
|
+
// it says nothing about a delegated file that appeared, or a file that GREW, since. Each stream's cursor remembers the size it
|
|
243
|
+
// scanned (`size`), so a stream whose file is a different size now has unread bytes, and a stream with no cursor at all was never read.
|
|
244
|
+
// A directory that cannot be LISTED is not an empty one (C17 #2): the delegated streams are unknown, so the session is not done.
|
|
245
|
+
const listing = findSubagentTranscriptsChecked(home, env, transcriptPath);
|
|
246
|
+
if (listing.listFailed)
|
|
247
|
+
return false;
|
|
248
|
+
const subs = listing.found;
|
|
249
|
+
let scanCurrent = record.scan.main?.size === size;
|
|
250
|
+
for (const sub of subs)
|
|
251
|
+
if (record.scan[sub.fileKey]?.size !== sub.size)
|
|
252
|
+
scanCurrent = false;
|
|
253
|
+
// The one exception is C21 #3: a FOLDER target the server refused for good can never be sent, so bytes appended after the refusal do
|
|
254
|
+
// not bring the session back (they could only ever be sent under that same folder).
|
|
255
|
+
const folderRefused = record.targets.some((t) => t.kind === "folder" && record.stopped[stopKeyOf(t)] !== undefined);
|
|
256
|
+
if (!scanCurrent && !folderRefused)
|
|
257
|
+
return false;
|
|
258
|
+
// ⛔ C29 (ii), the same rule for what was SENT: every stream of the target (main and each delegated file), not main alone.
|
|
259
|
+
const sentToEnd = (target) => {
|
|
260
|
+
const state = loadSessionState(home, { repoKey: target.ledgerKey, sessionId });
|
|
261
|
+
// ⛔ C35 (v): A QUIET SESSION IS NOT DONE UNTIL IT IS SEALED. An upload made while the transcript was recent or running leaves the target
|
|
262
|
+
// unfinalized; once the file has been quiet for the live window `uploadParent` would seal it, so the session must be offered again
|
|
263
|
+
// for that. (A session still running is not asked to seal: `quiet` is false.)
|
|
264
|
+
return state.endHold === null && streamsSentTo(state, size, subs, undefined) && (state.finalized !== null || !quiet);
|
|
265
|
+
};
|
|
266
|
+
const owed = new Set();
|
|
267
|
+
for (const info of Object.values(record.roots)) {
|
|
268
|
+
if (info.state !== "repo")
|
|
269
|
+
continue;
|
|
270
|
+
if (captureStanding(home, { worktree: info.toplevel, consent: info.commonDir }, info.slug) !== "granted")
|
|
271
|
+
continue;
|
|
272
|
+
owed.add(groupKey(info));
|
|
273
|
+
}
|
|
274
|
+
for (const key of owed) {
|
|
275
|
+
const target = record.targets.find((t) => t.kind === "repo" && t.groupKey === key);
|
|
276
|
+
// A target the server refused for good (C3: `record.stopped`) owes nothing: it can never be sent, and `status` names it. Without
|
|
277
|
+
// this the session would be offered again on every run and then reported with the wrong reason.
|
|
278
|
+
if (target !== undefined && record.stopped[stopKeyOf(target)] !== undefined)
|
|
279
|
+
continue;
|
|
280
|
+
if (target === undefined || !sentToEnd(target))
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
const noRepoNamed = Object.keys(record.roots).length === 0 && record.unresolved.length === 0 && !record.oversize;
|
|
284
|
+
const folder = record.targets.find((t) => t.kind === "folder");
|
|
285
|
+
if (folder !== undefined) {
|
|
286
|
+
if (record.stopped[stopKeyOf(folder)] !== undefined)
|
|
287
|
+
return true;
|
|
288
|
+
if (noRepoNamed && folderStandingOf(home, folder.folder ?? "") === "granted") {
|
|
289
|
+
return record.scan.main?.offset === size && sentToEnd(folder);
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
if (noRepoNamed && record.launchFolder !== null) {
|
|
294
|
+
try {
|
|
295
|
+
if (folderStandingOf(home, realpathSync(record.launchFolder)) === "granted")
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// A launch folder that is gone cannot be a folder target.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
166
304
|
function candidateOf(member, slug, sessionId, found) {
|
|
167
305
|
return {
|
|
168
306
|
agent: member.agent,
|
|
@@ -224,21 +362,42 @@ export function renderOffer(plan, options) {
|
|
|
224
362
|
.map((id) => `${SCAN.agent[id]} ${counts[id]}`)
|
|
225
363
|
.join(" · ");
|
|
226
364
|
const folders = plan.repos.map((repo) => folderRows(repo));
|
|
227
|
-
const widestRight = Math.max(...plan.repos.map(countsOf).map((text) => text.length), ...folders.flatMap((rows) => rows.map((row) => agentsOf(row.counts).length)));
|
|
365
|
+
const widestRight = Math.max(0, ...plan.repos.map(countsOf).map((text) => text.length), ...folders.flatMap((rows) => rows.map((row) => agentsOf(row.counts).length)));
|
|
228
366
|
// The column the right-hand text may start at without passing 80.
|
|
229
367
|
const limit = WRAP_COLUMNS - widestRight;
|
|
230
|
-
const slugWidth = Math.max(8, Math.min(SLUG_MAX, ...[Math.max(...plan.repos.map((repo) => repo.slug.length))], limit - ROW_INDENT - COLUMN_GAP));
|
|
368
|
+
const slugWidth = Math.max(8, Math.min(SLUG_MAX, ...[Math.max(0, ...plan.repos.map((repo) => repo.slug.length))], limit - ROW_INDENT - COLUMN_GAP));
|
|
231
369
|
const pathCap = Math.max(PATH_MIN, limit - FOLDER_INDENT - 2);
|
|
232
370
|
const pathOf = (dir) => truncatePath(tildePath(dir, home), pathCap);
|
|
233
|
-
const longestPath = Math.max(...folders.flatMap((rows) => rows.map((row) => pathOf(row.dir).length)));
|
|
371
|
+
const longestPath = Math.max(0, ...folders.flatMap((rows) => rows.map((row) => pathOf(row.dir).length)));
|
|
234
372
|
const column = Math.max(ROW_INDENT + slugWidth + COLUMN_GAP, FOLDER_INDENT + longestPath + 2);
|
|
235
|
-
const lines = [""
|
|
373
|
+
const lines = [""];
|
|
374
|
+
if (plan.repos.length > 0)
|
|
375
|
+
lines.push(...wrap(BACKFILL.offerHeading(plan.repos.length, options.standalone !== false), 2), "");
|
|
236
376
|
for (const [index, repo] of plan.repos.entries()) {
|
|
237
377
|
lines.push(`${" ".repeat(ROW_INDENT)}${paint(colour, "text", `${truncateMiddle(repo.slug, slugWidth).padEnd(column - ROW_INDENT)}${countsOf(repo)}`)}`);
|
|
238
378
|
for (const row of folders[index]) {
|
|
239
379
|
lines.push(`${" ".repeat(FOLDER_INDENT)}${paint(colour, "muted", `${pathOf(row.dir).padEnd(column - FOLDER_INDENT)}${agentsOf(row.counts)}`)}`);
|
|
240
380
|
}
|
|
241
381
|
}
|
|
382
|
+
const parent = plan.parent ?? [];
|
|
383
|
+
if (parent.length > 0) {
|
|
384
|
+
// R4: sessions run from folders outside a repository. Their repositories are not known until the transcript is read, after the
|
|
385
|
+
// yes, so this block says what will be CHECKED and what the check can find: never a promise of an upload.
|
|
386
|
+
if (plan.repos.length > 0)
|
|
387
|
+
lines.push("");
|
|
388
|
+
lines.push(...wrap(BACKFILL.parentHeading(parent.length), 2), "");
|
|
389
|
+
const byDir = new Map();
|
|
390
|
+
for (const candidate of parent)
|
|
391
|
+
byDir.set(candidate.dir, (byDir.get(candidate.dir) ?? 0) + 1);
|
|
392
|
+
const rows = [...byDir.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
393
|
+
const parentCap = Math.max(PATH_MIN, WRAP_COLUMNS - ROW_INDENT - COLUMN_GAP - BACKFILL.parentRowCounts(99999).length);
|
|
394
|
+
const shown = rows.map(([dir, count]) => ({ path: truncatePath(tildePath(dir, home), parentCap), count }));
|
|
395
|
+
const width = Math.max(...shown.map((row) => row.path.length));
|
|
396
|
+
for (const row of shown) {
|
|
397
|
+
lines.push(`${" ".repeat(ROW_INDENT)}${paint(colour, "text", `${row.path.padEnd(width + COLUMN_GAP)}${BACKFILL.parentRowCounts(row.count)}`)}`);
|
|
398
|
+
}
|
|
399
|
+
lines.push("", ...wrap(BACKFILL.parentNote, ROW_INDENT).map((line) => paint(colour, "muted", line)));
|
|
400
|
+
}
|
|
242
401
|
const notOffered = notOfferedLines(plan, colour, home);
|
|
243
402
|
if (notOffered.length > 0)
|
|
244
403
|
lines.push("", ...notOffered);
|
|
@@ -246,73 +405,26 @@ export function renderOffer(plan, options) {
|
|
|
246
405
|
return lines;
|
|
247
406
|
}
|
|
248
407
|
// ---------------------------------------------------------------------------
|
|
249
|
-
// The folder check — exception 1.
|
|
408
|
+
// The folder check — exception 1. It no longer reads a transcript itself.
|
|
250
409
|
// ---------------------------------------------------------------------------
|
|
251
|
-
// folder-check:begin
|
|
252
410
|
/**
|
|
253
|
-
* The `cwd` of the transcript's first record that has one. ⛔ ONE VALUE
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
411
|
+
* The `cwd` of the transcript's first record that has one. ⛔ ONE VALUE, and ONE READER: this is a call to
|
|
412
|
+
* `scanTranscriptKeys` (`session_targets.ts`, R13 · D15 · A), the only code that reads a field out of a transcript, with
|
|
413
|
+
* the spec narrowed to a top-level `cwd` and nothing else, so the 10-24 one-value reading of D60 §D6 exception 1 is exactly
|
|
414
|
+
* what backfill's own check still does. `none` (no record carries one in the first `FOLDER_CHECK_MAX_BYTES`) and
|
|
415
|
+
* `unreadable` (the file cannot be opened) both fail closed — the session is skipped — but they are different facts and
|
|
416
|
+
* are told apart.
|
|
257
417
|
*/
|
|
258
418
|
export function firstRecordedCwd(path) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return { kind: "unreadable" };
|
|
265
|
-
}
|
|
266
|
-
try {
|
|
267
|
-
const block = Buffer.alloc(64 * 1024);
|
|
268
|
-
let pending = Buffer.alloc(0);
|
|
269
|
-
let readBytes = 0;
|
|
270
|
-
while (readBytes < FOLDER_CHECK_MAX_BYTES) {
|
|
271
|
-
const n = readSync(fd, block, 0, block.length, readBytes);
|
|
272
|
-
if (n === 0)
|
|
273
|
-
break;
|
|
274
|
-
readBytes += n;
|
|
275
|
-
pending = Buffer.concat([pending, block.subarray(0, n)]);
|
|
276
|
-
for (let end = pending.indexOf(0x0a); end >= 0; end = pending.indexOf(0x0a)) {
|
|
277
|
-
const line = pending.subarray(0, end).toString("utf8");
|
|
278
|
-
pending = pending.subarray(end + 1);
|
|
279
|
-
if (!line.includes('"cwd"'))
|
|
280
|
-
continue;
|
|
281
|
-
const cwd = cwdOfLine(line);
|
|
282
|
-
if (cwd !== null)
|
|
283
|
-
return { kind: "found", cwd };
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
const last = cwdOfLine(pending.toString("utf8"));
|
|
287
|
-
return last === null ? { kind: "none" } : { kind: "found", cwd: last };
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
419
|
+
const scan = scanTranscriptKeys(path, 0, FOLDER_CHECK_MAX_BYTES, TOP_LEVEL_CWD_ONLY, {
|
|
420
|
+
first: true,
|
|
421
|
+
includeUnterminatedTail: true,
|
|
422
|
+
});
|
|
423
|
+
if (scan.kind === "unreadable")
|
|
290
424
|
return { kind: "unreadable" };
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
try {
|
|
294
|
-
closeSync(fd);
|
|
295
|
-
}
|
|
296
|
-
catch {
|
|
297
|
-
// Nothing useful to do.
|
|
298
|
-
}
|
|
299
|
-
}
|
|
425
|
+
const found = scan.hits.find((hit) => hit.kind === "cwd");
|
|
426
|
+
return found === undefined ? { kind: "none" } : { kind: "found", cwd: found.value };
|
|
300
427
|
}
|
|
301
|
-
function cwdOfLine(line) {
|
|
302
|
-
if (!line.includes('"cwd"'))
|
|
303
|
-
return null;
|
|
304
|
-
try {
|
|
305
|
-
const record = JSON.parse(line);
|
|
306
|
-
if (record === null || typeof record !== "object" || Array.isArray(record))
|
|
307
|
-
return null;
|
|
308
|
-
const cwd = record.cwd;
|
|
309
|
-
return typeof cwd === "string" && cwd !== "" ? cwd : null;
|
|
310
|
-
}
|
|
311
|
-
catch {
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
// folder-check:end
|
|
316
428
|
/**
|
|
317
429
|
* Does the folder the session recorded still resolve to the clone it was matched to?
|
|
318
430
|
*
|
|
@@ -357,6 +469,10 @@ function classify(outcome) {
|
|
|
357
469
|
return { kind: "uploaded" };
|
|
358
470
|
case "never-captured":
|
|
359
471
|
return { kind: "failed", reason: BACKFILL.refusedByServer };
|
|
472
|
+
case "lock-lost":
|
|
473
|
+
// C25: another writer took the session while this run held it; nothing more was written. The session is "busy", as when the
|
|
474
|
+
// lock could not be had at all, and a later run uploads what is left.
|
|
475
|
+
return { kind: "skipped", reason: BACKFILL.parentBusy };
|
|
360
476
|
case "not-delivered":
|
|
361
477
|
case "subagents-held": {
|
|
362
478
|
const delivery = outcome.delivery;
|
|
@@ -415,32 +531,175 @@ async function uploadOne(run, candidate) {
|
|
|
415
531
|
if (!folderMatches(recorded.cwd, candidate.commonDir))
|
|
416
532
|
return { kind: "skipped", reason: BACKFILL.folderDiffers };
|
|
417
533
|
}
|
|
418
|
-
let slug = run.slugs.get(candidate.clone);
|
|
419
|
-
if (slug === undefined) {
|
|
420
|
-
slug = resolveRepoSlug(keys.worktree);
|
|
421
|
-
run.slugs.set(candidate.clone, slug);
|
|
422
|
-
}
|
|
423
534
|
const quiet = ctx.now().getTime() - found.mtimeMs >= SESSION_LIVE_WINDOW_MS;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
535
|
+
// ⛔ EVERY BACKFILL UPLOAD GOES THROUGH THE ONE RESOLVER, UNDER THE SESSION'S ONE WRITER LOCK (C18 #3). This path used to call
|
|
536
|
+
// `closeSession` with `keys.worktree` directly: no lock (it could interleave with a live hook on the same session) and no sticky
|
|
537
|
+
// ledger (a session the hook already adopted another work tree's ledger for would start a second ledger and re-send from 0). The
|
|
538
|
+
// resolver gives the target this repo already has for the session, ledger and slug included.
|
|
539
|
+
//
|
|
540
|
+
// ⛔ NO FALLBACK UPLOAD (C21 #1). When the resolver returns no target for this repo, the session is SKIPPED: nothing is sent and no
|
|
541
|
+
// ledger is written. It returns none when the strict slug failed (git timed out: the old fallback answered `local:<hash>`, a WRONG
|
|
542
|
+
// repository identity that a later hook would then adopt along with its ledger) or when the root was deferred (a fresh ledger at
|
|
543
|
+
// `keys.worktree` is the double send that deferral exists to prevent). The next run, with git answering, uploads it properly.
|
|
544
|
+
const releases = [];
|
|
545
|
+
try {
|
|
546
|
+
// `turnEndCwd` is the folder the OFFER verified this session belongs to (the agent's own index, or the first `cwd` the transcript
|
|
547
|
+
// records): the evidence that put it in this repo's offer, handed to the resolver so a transcript with no `cwd` of its own still
|
|
548
|
+
// resolves. It cannot name a repo the offer did not (the candidate is this repo's) and it is no fallback identity.
|
|
549
|
+
const resolved = await resolveSessionTargets({ home: ctx.home, env: ctx.env, nowMs: ctx.now().getTime(), turnEndCwd: candidate.dir }, candidate.sessionId, candidate.path, { keys, slugOf: onceStrictRepoSlug(keys.worktree) }, releases);
|
|
550
|
+
if (resolved.kind === "busy")
|
|
551
|
+
return { kind: "skipped", reason: BACKFILL.parentBusy };
|
|
552
|
+
const heartbeat = resolved.heartbeat;
|
|
553
|
+
// This repo's target: one of its work trees, or any work tree of the same git common dir (a session recorded in a LINKED work tree
|
|
554
|
+
// belongs to the clone the offer was made for).
|
|
555
|
+
const own = resolved.targets.find((target) => target.kind === "repo" &&
|
|
556
|
+
(target.worktrees.includes(keys.worktree) || target.ledgerKey === keys.worktree || target.commonDir === keys.consent));
|
|
557
|
+
if (own === undefined)
|
|
558
|
+
return { kind: "failed", reason: BACKFILL.noRepoIdentity };
|
|
559
|
+
const spec = closeSpecOf(own, resolved.scan, resolved);
|
|
560
|
+
const outcome = await run.close({
|
|
561
|
+
home: ctx.home,
|
|
562
|
+
env: ctx.env,
|
|
563
|
+
nodeVersion: ctx.nodeVersion,
|
|
564
|
+
agentId: candidate.agent,
|
|
565
|
+
cwd: candidate.dir,
|
|
566
|
+
url: run.url,
|
|
567
|
+
credential: run.credential,
|
|
568
|
+
projectKey: spec.projectKey,
|
|
569
|
+
redactionRoots: spec.redactionRoots,
|
|
570
|
+
repoSlug: spec.repoSlug,
|
|
571
|
+
sessionId: candidate.sessionId,
|
|
572
|
+
transcriptPath: candidate.path,
|
|
573
|
+
eof: found.size,
|
|
574
|
+
// Sealed only when the transcript has been quiet for 30 minutes and it is not the session running this command.
|
|
575
|
+
seal: quiet && !run.running.has(candidate.sessionId),
|
|
576
|
+
allowNeverCaptured: true,
|
|
577
|
+
completeSubagents: true,
|
|
578
|
+
worktrees: spec.worktrees,
|
|
579
|
+
...(spec.isolation === undefined ? {} : { isolation: spec.isolation }),
|
|
580
|
+
heartbeat,
|
|
581
|
+
});
|
|
582
|
+
return classify(outcome);
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
for (const release of releases) {
|
|
586
|
+
try {
|
|
587
|
+
release();
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// A lock that cannot be released ages out by itself.
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* One parent-folder session (R4): find, by the resolver `finalize` and the hook use, every granted repository it touched (and the
|
|
597
|
+
* folder, when it touched none and the folder is granted), then close it once per target through `closeSession`, so a
|
|
598
|
+
* multi-repo session uploads once per granted repo with each repo's own redaction scope. ⛔ The first read of this transcript
|
|
599
|
+
* happens HERE, after the yes and after the consent check, one session at a time.
|
|
600
|
+
*/
|
|
601
|
+
async function uploadParent(run, candidate) {
|
|
602
|
+
const { ctx } = run;
|
|
603
|
+
const found = statFile(candidate.path);
|
|
604
|
+
if (found === null)
|
|
605
|
+
return { kind: "skipped", reason: BACKFILL.fileGone };
|
|
606
|
+
if (found.size > MAX_SESSION_BYTES)
|
|
607
|
+
return { kind: "skipped", reason: BACKFILL.overSize };
|
|
608
|
+
if (!admitsTranscript(ctx.home, ctx.env, candidate.path))
|
|
609
|
+
return { kind: "skipped", reason: BACKFILL.outsideAgentFolder };
|
|
610
|
+
// ⛔ NOTHING IS READ without some consent on the machine (`vibecommit off` everywhere since the offer leaves nothing to find).
|
|
611
|
+
if (!hasAnyConsent(ctx.home))
|
|
612
|
+
return { kind: "skipped", reason: BACKFILL.turnedOff };
|
|
613
|
+
const releases = [];
|
|
614
|
+
try {
|
|
615
|
+
const resolved = await resolveSessionTargets({ home: ctx.home, env: ctx.env, nowMs: ctx.now().getTime(), turnEndCwd: null }, candidate.sessionId, candidate.path, null, releases);
|
|
616
|
+
if (resolved.kind === "busy")
|
|
617
|
+
return { kind: "skipped", reason: BACKFILL.parentBusy };
|
|
618
|
+
const heartbeat = resolved.heartbeat;
|
|
619
|
+
if (resolved.targets.length === 0)
|
|
620
|
+
return { kind: "skipped", reason: BACKFILL.parentTouchedNothing };
|
|
621
|
+
const quiet = ctx.now().getTime() - found.mtimeMs >= SESSION_LIVE_WINDOW_MS;
|
|
622
|
+
const results = [];
|
|
623
|
+
for (const target of resolved.targets) {
|
|
624
|
+
const spec = closeSpecOf(target, resolved.scan, resolved);
|
|
625
|
+
// ⛔ A target that is already delivered to the end of what this run would send, and already sealed when this run would seal
|
|
626
|
+
// (or would not seal), owes nothing (C18 #9): a repo chosen LATER brings the session back into the offer, and only the repos
|
|
627
|
+
// that still owe bytes are uploaded. Re-closing the others would send each an empty final request for nothing.
|
|
628
|
+
const sealing = quiet && !run.running.has(candidate.sessionId);
|
|
629
|
+
const toEof = spec.sizeCap === undefined ? found.size : Math.min(found.size, spec.sizeCap.main ?? 0);
|
|
630
|
+
const ledger = loadSessionState(ctx.home, { repoKey: spec.projectKey, sessionId: candidate.sessionId });
|
|
631
|
+
// ⛔ C29 (ii): ONLY WHEN EVERY STREAM IS AT ITS END. Main alone said "delivered" for a session whose delegated files (new since, or
|
|
632
|
+
// never sent) still owed bytes, and the target was skipped with them unsent.
|
|
633
|
+
// A listing that FAILED (EACCES, EIO) is "unknown", never "no delegated streams": the target is not skipped, and `run.close` handles it.
|
|
634
|
+
const subListing = findSubagentTranscriptsChecked(ctx.home, ctx.env, candidate.path);
|
|
635
|
+
if (!subListing.listFailed &&
|
|
636
|
+
ledger.endHold === null &&
|
|
637
|
+
streamsSentTo(ledger, toEof, subListing.found, spec.sizeCap) &&
|
|
638
|
+
(ledger.finalized !== null || !sealing)) {
|
|
639
|
+
results.push({ kind: "uploaded" });
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const outcome = await run.close({
|
|
643
|
+
home: ctx.home,
|
|
644
|
+
env: ctx.env,
|
|
645
|
+
nodeVersion: ctx.nodeVersion,
|
|
646
|
+
agentId: candidate.agent,
|
|
647
|
+
cwd: candidate.dir,
|
|
648
|
+
url: run.url,
|
|
649
|
+
credential: run.credential,
|
|
650
|
+
projectKey: spec.projectKey,
|
|
651
|
+
redactionRoots: spec.redactionRoots,
|
|
652
|
+
repoSlug: spec.repoSlug,
|
|
653
|
+
sessionId: candidate.sessionId,
|
|
654
|
+
transcriptPath: candidate.path,
|
|
655
|
+
eof: spec.sizeCap === undefined ? found.size : Math.min(found.size, spec.sizeCap.main ?? 0),
|
|
656
|
+
seal: quiet && !run.running.has(candidate.sessionId),
|
|
657
|
+
allowNeverCaptured: true,
|
|
658
|
+
completeSubagents: true,
|
|
659
|
+
worktrees: spec.worktrees,
|
|
660
|
+
...(spec.sizeCap === undefined ? {} : { sizeCap: spec.sizeCap }),
|
|
661
|
+
...(spec.isolation === undefined ? {} : { isolation: spec.isolation }),
|
|
662
|
+
heartbeat,
|
|
663
|
+
});
|
|
664
|
+
// C25: the lock was taken from us. Stop BEFORE the next target's write.
|
|
665
|
+
if (outcome.kind === "lock-lost") {
|
|
666
|
+
results.push(classify(outcome));
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
// C3: the server refused THIS folder target for good. Stopped and recorded; it does not fail the session or the run.
|
|
670
|
+
if ("delivery" in outcome &&
|
|
671
|
+
outcome.delivery.kind === "attempted" &&
|
|
672
|
+
outcome.delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
|
|
673
|
+
// C35 (iii): the stop is a write, so only while the session is still ours (the 403 is reported either way).
|
|
674
|
+
if (heartbeat())
|
|
675
|
+
stopTarget(ctx.home, candidate.sessionId, target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
|
|
676
|
+
results.push({ kind: "skipped", reason: BACKFILL.parentFolderRefused });
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const result = classify(outcome);
|
|
680
|
+
results.push(result.kind === "held" ? { kind: "held", slug: target.slug } : result);
|
|
681
|
+
}
|
|
682
|
+
// One answer for the session: a stop of the run wins, then a hold, then a failure; else it uploaded if anything did.
|
|
683
|
+
return (results.find((r) => r.kind === "rejected") ??
|
|
684
|
+
results.find((r) => r.kind === "stopped") ??
|
|
685
|
+
results.find((r) => r.kind === "held") ??
|
|
686
|
+
results.find((r) => r.kind === "failed") ??
|
|
687
|
+
// C25: a lock lost part-way leaves a target unsent, so the session is not "uploaded" however many targets went first. It is
|
|
688
|
+
// busy: a later run finds what is still owed (`parentDone` reads the ledgers) and sends it.
|
|
689
|
+
results.find((r) => r.kind === "skipped" && r.reason === BACKFILL.parentBusy) ??
|
|
690
|
+
results.find((r) => r.kind === "uploaded") ??
|
|
691
|
+
results[0]);
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
for (const release of releases) {
|
|
695
|
+
try {
|
|
696
|
+
release();
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
// A lock that cannot be released ages out by itself.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
444
703
|
}
|
|
445
704
|
const newTally = () => ({
|
|
446
705
|
uploaded: 0,
|
|
@@ -581,31 +840,41 @@ async function upload(ctx, plan, connection, deps, signal) {
|
|
|
581
840
|
close: deps.closeSession ?? closeSession,
|
|
582
841
|
readCwd: deps.readCwd ?? firstRecordedCwd,
|
|
583
842
|
running: runningSessionIds(ctx.env),
|
|
584
|
-
slugs: new Map(),
|
|
585
843
|
};
|
|
586
|
-
const
|
|
587
|
-
const
|
|
844
|
+
const parent = plan.parent ?? [];
|
|
845
|
+
const names = [...plan.repos.map((repo) => repo.slug), ...(parent.length > 0 ? [BACKFILL.parentLabel] : [])];
|
|
846
|
+
const column = ROW_INDENT + Math.min(SLUG_MAX, Math.max(...names.map((name) => name.length))) + COLUMN_GAP;
|
|
847
|
+
const nameLine = (name, text) => `${" ".repeat(ROW_INDENT)}${truncateMiddle(name, column - ROW_INDENT - COLUMN_GAP).padEnd(column - ROW_INDENT)}${text}`;
|
|
848
|
+
/** A repo's sessions, then the parent-folder group. A repo whose owner has not approved capture is held whole; the parent group is not (its targets differ per session). */
|
|
849
|
+
const groups = [
|
|
850
|
+
...plan.repos.map((repo) => ({ name: repo.slug, slug: repo.slug, items: repo.candidates })),
|
|
851
|
+
...(parent.length > 0 ? [{ name: BACKFILL.parentLabel, slug: null, items: parent }] : []),
|
|
852
|
+
];
|
|
588
853
|
writeLines(ctx.stdout, [...wrap(BACKFILL.runHeading(plan.total), 2)]);
|
|
589
854
|
let uploaded = 0;
|
|
590
855
|
let skipped = 0;
|
|
591
856
|
let held = 0;
|
|
592
857
|
let failed = 0;
|
|
593
858
|
const heldRepos = [];
|
|
594
|
-
for (const
|
|
859
|
+
for (const group of groups) {
|
|
595
860
|
const tally = newTally();
|
|
596
861
|
let slowShown = false;
|
|
597
862
|
const timer = setTimeout(() => {
|
|
598
863
|
// A static line, overwritten once when the repo finishes — and only where an overwrite is possible (§13.8).
|
|
599
864
|
slowShown = true;
|
|
600
|
-
ctx.stdout.write(`${paint(ctx.colour, "muted",
|
|
865
|
+
ctx.stdout.write(`${paint(ctx.colour, "muted", nameLine(group.name, "…"))}`);
|
|
601
866
|
}, deps.slowAfterMs ?? SLOW_REPO_MS);
|
|
602
867
|
let heldRepo = false;
|
|
603
868
|
try {
|
|
604
|
-
for (const candidate of
|
|
869
|
+
for (const candidate of group.items) {
|
|
605
870
|
if (signal.aborted)
|
|
606
871
|
break;
|
|
607
872
|
// A repo whose owner has not approved capture is held whole: the rest of it is not even tried.
|
|
608
|
-
const result = heldRepo
|
|
873
|
+
const result = heldRepo && group.slug !== null
|
|
874
|
+
? { kind: "held" }
|
|
875
|
+
: "clone" in candidate
|
|
876
|
+
? await uploadOne(run, candidate)
|
|
877
|
+
: await uploadParent(run, candidate);
|
|
609
878
|
if (result.kind === "uploaded")
|
|
610
879
|
tally.uploaded += 1;
|
|
611
880
|
else if (result.kind === "skipped")
|
|
@@ -614,7 +883,10 @@ async function upload(ctx, plan, connection, deps, signal) {
|
|
|
614
883
|
bump(tally.failed, result.reason);
|
|
615
884
|
else if (result.kind === "held") {
|
|
616
885
|
tally.held += 1;
|
|
617
|
-
|
|
886
|
+
if (group.slug !== null)
|
|
887
|
+
heldRepo = true;
|
|
888
|
+
else if (result.slug !== undefined)
|
|
889
|
+
heldRepos.push(result.slug);
|
|
618
890
|
}
|
|
619
891
|
else {
|
|
620
892
|
// `stopped` (the server asked to try later, or could not be reached) and `rejected` (the credential was refused)
|
|
@@ -639,10 +911,10 @@ async function upload(ctx, plan, connection, deps, signal) {
|
|
|
639
911
|
skipped += sumOf(tally.skipped);
|
|
640
912
|
failed += sumOf(tally.failed);
|
|
641
913
|
held += tally.held;
|
|
642
|
-
if (tally.held > 0)
|
|
643
|
-
heldRepos.push(
|
|
914
|
+
if (tally.held > 0 && group.slug !== null)
|
|
915
|
+
heldRepos.push(group.slug);
|
|
644
916
|
const role = tally.held > 0 || tally.stopped ? "warn" : "text";
|
|
645
|
-
const line = paint(ctx.colour, role,
|
|
917
|
+
const line = paint(ctx.colour, role, nameLine(group.name, tallyLine(tally)));
|
|
646
918
|
// Overwrite the `…` line in place on a terminal; elsewhere it stays and the result prints beneath it.
|
|
647
919
|
ctx.stdout.write(slowShown && ctx.colour ? `\r${line}\n` : `${slowShown ? "\n" : ""}${line}\n`);
|
|
648
920
|
if (tally.stopped)
|
|
@@ -705,8 +977,14 @@ const NOTHING_NOT_LISTED = {
|
|
|
705
977
|
* user did not choose is dropped by `planBackfill` however it got into `repos`; so is a clone with no reflog, which the plan asks
|
|
706
978
|
* git about itself.
|
|
707
979
|
*/
|
|
708
|
-
export async function backfillForConnect(ctx, repos,
|
|
709
|
-
|
|
980
|
+
export async function backfillForConnect(ctx, repos,
|
|
981
|
+
/** The folders outside every repository `connect`'s own scan found: their sessions are the parent-folder offer (R4). */
|
|
982
|
+
folders = [], deps = {}) {
|
|
983
|
+
const result = await runBackfill(ctx, {
|
|
984
|
+
...deps,
|
|
985
|
+
fromConnect: true,
|
|
986
|
+
scanMachine: () => ({ repos, folders, notListed: NOTHING_NOT_LISTED }),
|
|
987
|
+
});
|
|
710
988
|
switch (result.end) {
|
|
711
989
|
case "declined":
|
|
712
990
|
case "nothing":
|