@outerlayer/cli 0.1.1 → 0.2.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.
Files changed (28) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +14 -20
  3. package/dist/build-info.json +1 -1
  4. package/dist/{chunk-BQB3U2VD.js → chunk-4TNZMO7V.js} +1874 -3191
  5. package/dist/{chunk-RCQXYLMO.js → chunk-65WXAANR.js} +2 -17
  6. package/dist/{chunk-KKEU6FFR.js → chunk-DCNOXRMV.js} +1 -1
  7. package/dist/{chunk-IWIUYLDR.js → chunk-I3ETLSNE.js} +7 -187
  8. package/dist/{chunk-3VQ4XXQA.js → chunk-NXLLURA4.js} +3 -3
  9. package/dist/{chunk-HNCCD2IX.js → chunk-Q6CL5THG.js} +3 -2
  10. package/dist/chunk-U32VSRLO.js +2225 -0
  11. package/dist/chunk-WZBHBH3J.js +20 -0
  12. package/dist/{cli-EFJIG2VT.js → cli-G7DILYJY.js} +48 -130
  13. package/dist/{context-materialize-J6CRQ7O7.js → context-materialize-6WKT3RBQ.js} +1 -1
  14. package/dist/{emit-artifact-cmd-KPPD3YLO.js → emit-artifact-cmd-UQVT3OKW.js} +6 -4
  15. package/dist/{emit-finding-cmd-VR5VEGXJ.js → emit-finding-cmd-FDOU2OPD.js} +6 -4
  16. package/dist/{emit-result-cmd-YOA5BJKL.js → emit-result-cmd-H2T4C2K3.js} +6 -4
  17. package/dist/{hook-fast-I2XTHDE6.js → hook-fast-J5LCDHUJ.js} +2 -2
  18. package/dist/index.js +2 -2
  19. package/dist/{loop-BWE2BS6J.js → loop-ASZRX3CZ.js} +10 -8
  20. package/dist/{status-JHVSZYC5.js → status-I27IST4L.js} +8 -6
  21. package/dist/{sync-cmd-S7Q6DWU3.js → sync-cmd-BBAUZ5JD.js} +6 -4
  22. package/dist/{watch-NRLXJUST.js → watch-3DTPJETH.js} +6 -4
  23. package/dist/{work-claim-cmd-HXB7K27H.js → work-claim-cmd-5AUNCLS2.js} +8 -6
  24. package/dist/{work-cmd-ABP6XDBP.js → work-cmd-2P4BVX47.js} +7 -5
  25. package/dist/{work-launch-YNNCKIH3.js → work-launch-LT663PB3.js} +2 -1
  26. package/dist/{work-pr-cmd-GOYSPEN2.js → work-pr-cmd-AZQPWH4H.js} +6 -4
  27. package/package.json +1 -1
  28. package/dist/emit-commit-credit-cmd-656BRAQ4.js +0 -121
@@ -0,0 +1,2225 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { tailTranscript, enrichSessionRepo, CAPTURE_TIERS, scanAll, setRepoRoot, resolveRepoIdentity, inferArtifactKind, bannedPathsForTier, sessionIdFromPath, setCustomScrubbers, setLocalIdentity, scrubDeep, downconvertSession, isHumanUserTurn, turnCountBound, SOURCE_ADAPTERS, EVENT_TYPES, ArtifactTombstoneRecordSchema, ArtifactSpoolRecordSchema } from './chunk-4TNZMO7V.js';
4
+ import { C } from './chunk-YRYMNBGQ.js';
5
+ import { readLaunchSpoolState, launchesPath, launchStatusIsDecisive } from './chunk-65WXAANR.js';
6
+ import { readLatestProvenanceForSession, readContextProvenance } from './chunk-ZJSNLVCE.js';
7
+ import { writeSyncStatus, spoolDir } from './chunk-NXLLURA4.js';
8
+ import { removeJournalHooks } from './chunk-I3ETLSNE.js';
9
+ import { hookExecSpoolPath } from './chunk-YOOSBOKS.js';
10
+ import { readCloudConfig, cloudConfigPath, writeCloudConfig } from './chunk-TFUIDMOB.js';
11
+ import { homeFromEnv } from './chunk-VNVZDWO3.js';
12
+ import { dirname, basename, join } from 'path';
13
+ import { statSync, mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, openSync, fstatSync, readSync, closeSync, writeFileSync, renameSync, unlinkSync, rmSync } from 'fs';
14
+ import { execFileSync } from 'child_process';
15
+ import { hostname, userInfo } from 'os';
16
+ import { fileURLToPath } from 'url';
17
+ import { createRequire as createRequire$1 } from 'module';
18
+
19
+ createRequire(import.meta.url);
20
+ function detectCi(env = process.env) {
21
+ return env.CI === "true" || env.CI === "1" || !!env.JENKINS_URL;
22
+ }
23
+ function gitGlobalConfig(key) {
24
+ try {
25
+ return execFileSync("git", ["config", "--global", key], { encoding: "utf8", timeout: 2e3 }).trim() || void 0;
26
+ } catch {
27
+ return void 0;
28
+ }
29
+ }
30
+ function installScrubContext(home, scrub) {
31
+ setCustomScrubbers(scrub);
32
+ setLocalIdentity({
33
+ name: gitGlobalConfig("user.name"),
34
+ email: gitGlobalConfig("user.email"),
35
+ username: (() => {
36
+ try {
37
+ return userInfo().username;
38
+ } catch {
39
+ return void 0;
40
+ }
41
+ })(),
42
+ hostname: (() => {
43
+ try {
44
+ return hostname();
45
+ } catch {
46
+ return void 0;
47
+ }
48
+ })(),
49
+ home
50
+ });
51
+ }
52
+ function applyContextProvenance(session, record) {
53
+ if (!record) return;
54
+ if (record.repository !== void 0) session.env.contextSourceRepo = record.repository;
55
+ if (record.ref !== void 0) session.env.contextSourceRef = record.ref;
56
+ if (record.sha !== null) session.env.contextSourceSha = record.sha;
57
+ session.env.contextSourceVerification = record.verification;
58
+ }
59
+ function prepareSessionForUpload(session, tier, envVars = process.env) {
60
+ setRepoRoot(resolveRepoIdentity(session.env.cwd).repoRoot);
61
+ scrubDeep(session);
62
+ session.workerKind ??= detectCi(envVars) ? "ci" : "seat";
63
+ return downconvertSession(session, tier);
64
+ }
65
+ var MAX_TOOL_IO_CHARS = 64e3;
66
+ function sliceToolIo(value) {
67
+ const text = typeof value === "string" ? value : JSON.stringify(value);
68
+ if (typeof text !== "string" || text.length <= MAX_TOOL_IO_CHARS) return value;
69
+ return `${text.slice(0, MAX_TOOL_IO_CHARS)}\u2026[sliced: ${text.length} chars exceeded the sync per-call cap]`;
70
+ }
71
+ function sliceTurnIo(turn) {
72
+ return {
73
+ ...turn,
74
+ toolCalls: (turn.toolCalls ?? []).map((call) => ({
75
+ ...call,
76
+ ...call.input !== void 0 ? { input: sliceToolIo(call.input) } : {},
77
+ ...call.output !== void 0 ? { output: sliceToolIo(call.output) } : {}
78
+ }))
79
+ };
80
+ }
81
+ function wholeSessionCounts(turns) {
82
+ const allCalls = turns.flatMap((t) => t.toolCalls ?? []);
83
+ return {
84
+ // An index BOUND (`1 + max(turn.index)`), not the shipped turn count —
85
+ // turn indexes are not guaranteed dense from zero, and a late artifact
86
+ // anchored at its real turn index must still validate against the
87
+ // summary row this produces (see turnCountBound).
88
+ turns: turnCountBound(turns),
89
+ toolCalls: allCalls.length,
90
+ errors: allCalls.filter((c) => c.status === "error").length,
91
+ userTurns: turns.filter(isHumanUserTurn).length
92
+ };
93
+ }
94
+ var IRRELEVANT = /* @__PURE__ */ new Set(["__tests__", "__mocks__", "node_modules"]);
95
+ function newestSourceMs(dir) {
96
+ let newest = 0;
97
+ let entries;
98
+ try {
99
+ entries = readdirSync(dir, { withFileTypes: true });
100
+ } catch {
101
+ return 0;
102
+ }
103
+ for (const entry of entries) {
104
+ if (entry.isDirectory()) {
105
+ if (IRRELEVANT.has(entry.name)) continue;
106
+ newest = Math.max(newest, newestSourceMs(join(dir, entry.name)));
107
+ continue;
108
+ }
109
+ if (!entry.name.endsWith(".ts")) continue;
110
+ if (entry.name.endsWith(".test.ts")) continue;
111
+ try {
112
+ newest = Math.max(newest, statSync(join(dir, entry.name)).mtimeMs);
113
+ } catch {
114
+ }
115
+ }
116
+ return newest;
117
+ }
118
+ function staleBuildWarning(entryPath = fileURLToPath(import.meta.url)) {
119
+ const distDir = dirname(entryPath);
120
+ if (basename(distDir) !== "dist") return null;
121
+ const srcDir = join(dirname(distDir), "src");
122
+ const sourceMs = newestSourceMs(srcDir);
123
+ if (sourceMs === 0) return null;
124
+ let builtMs;
125
+ try {
126
+ builtMs = statSync(entryPath).mtimeMs;
127
+ } catch {
128
+ return null;
129
+ }
130
+ if (sourceMs <= builtMs) return null;
131
+ const age = Math.round((sourceMs - builtMs) / 1e3);
132
+ const howOld = age >= 86400 ? `${Math.round(age / 86400)}d` : age >= 3600 ? `${Math.round(age / 3600)}h` : `${age}s`;
133
+ return `this CLI build is ${howOld} older than its sources \u2014 run \`yarn workspace @outerlayer/cli build\`. Until you do, it may write data the current format cannot read.`;
134
+ }
135
+ var ALLOW_STALE_BUILD_ENV = "OUTERLAYER_ALLOW_STALE_BUILD";
136
+ function staleBuildRefusal(env = process.env, entryPath = fileURLToPath(import.meta.url)) {
137
+ const warning = staleBuildWarning(entryPath);
138
+ if (!warning) return null;
139
+ if (env[ALLOW_STALE_BUILD_ENV] === "1") return null;
140
+ if (env.CI === "true" || env.CI === "1" || env.JENKINS_URL) return null;
141
+ return `${warning.replace(/ Until you do,.*$/, "")} Refusing to write with it; set ${ALLOW_STALE_BUILD_ENV}=1 to write anyway.`;
142
+ }
143
+ function watermarkPath(home = homeFromEnv()) {
144
+ return join(home, ".outerlayer", "watermarks.json");
145
+ }
146
+ function watermarkKeyFor(url, appId) {
147
+ return `${new URL(url).host}:${appId}`;
148
+ }
149
+ function readFile(home) {
150
+ try {
151
+ const parsed = JSON.parse(readFileSync(watermarkPath(home), "utf8"));
152
+ if (!parsed || typeof parsed !== "object") return {};
153
+ const out = {};
154
+ for (const [k, v] of Object.entries(parsed)) {
155
+ if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
156
+ }
157
+ return out;
158
+ } catch {
159
+ return {};
160
+ }
161
+ }
162
+ function readWatermark(url, appId, home = homeFromEnv(), migrateFn = readSqliteWatermark) {
163
+ const key = watermarkKeyFor(url, appId);
164
+ const file = readFile(home);
165
+ if (key in file) return file[key];
166
+ const migrated = migrateFn(url, appId, home);
167
+ writeWatermark(url, appId, migrated ?? 0, home);
168
+ return migrated ?? 0;
169
+ }
170
+ function writeWatermark(url, appId, mtimeMs, home = homeFromEnv()) {
171
+ try {
172
+ const key = watermarkKeyFor(url, appId);
173
+ const file = readFile(home);
174
+ file[key] = mtimeMs;
175
+ const path = watermarkPath(home);
176
+ mkdirSync(join(home, ".outerlayer"), { recursive: true });
177
+ const tmp = `${path}.tmp`;
178
+ writeFileSync(tmp, JSON.stringify(file, null, 2));
179
+ renameSync(tmp, path);
180
+ } catch {
181
+ }
182
+ }
183
+ function readSqliteWatermark(url, appId, home) {
184
+ try {
185
+ const dbPath = join(home, ".outerlayer", "db.sqlite");
186
+ if (!existsSync(dbPath)) return null;
187
+ const { DatabaseSync } = createRequire$1(import.meta.url)("node:sqlite");
188
+ const db = new DatabaseSync(dbPath, { readOnly: true });
189
+ try {
190
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(`sync_watermark_ms:${new URL(url).host}:${appId}`);
191
+ if (!row) return null;
192
+ const ms = parseFloat(row.value);
193
+ return Number.isFinite(ms) ? ms : null;
194
+ } finally {
195
+ db.close();
196
+ }
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ // src/repo-filter.ts
203
+ function toMatcher(pattern) {
204
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
205
+ return new RegExp(`^${escaped}$`, "i");
206
+ }
207
+ var RepoFilter = class {
208
+ constructor(config) {
209
+ /** Sessions dropped, keyed by repo (or "(no repo)") — for the sync summary. */
210
+ this.skipped = /* @__PURE__ */ new Map();
211
+ this.include = (config?.include ?? []).map(toMatcher);
212
+ this.exclude = (config?.exclude ?? []).map(toMatcher);
213
+ }
214
+ get active() {
215
+ return this.include.length > 0 || this.exclude.length > 0;
216
+ }
217
+ /** True when the session should sync. Records skips for reporting. */
218
+ allows(repo) {
219
+ const label = repo || "(no repo)";
220
+ if (repo && this.exclude.some((m) => m.test(repo))) {
221
+ this.skipped.set(label, (this.skipped.get(label) ?? 0) + 1);
222
+ return false;
223
+ }
224
+ if (this.include.length > 0 && !(repo && this.include.some((m) => m.test(repo)))) {
225
+ this.skipped.set(label, (this.skipped.get(label) ?? 0) + 1);
226
+ return false;
227
+ }
228
+ return true;
229
+ }
230
+ get skippedTotal() {
231
+ let n = 0;
232
+ for (const c of this.skipped.values()) n += c;
233
+ return n;
234
+ }
235
+ };
236
+ var SESSION_DETECT_TAIL_BYTES = 64 * 1024;
237
+ var SESSION_DETECT_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
238
+ var readTail = (path, maxBytes) => {
239
+ try {
240
+ const fd = openSync(path, "r");
241
+ try {
242
+ const size = fstatSync(fd).size;
243
+ const length = Math.min(size, maxBytes);
244
+ const data = Buffer.alloc(length);
245
+ readSync(fd, data, 0, length, size - length);
246
+ return { data, truncated: size > length };
247
+ } finally {
248
+ closeSync(fd);
249
+ }
250
+ } catch {
251
+ return void 0;
252
+ }
253
+ };
254
+ function detectActiveSession(opts) {
255
+ if (!opts.env.CLAUDECODE) return void 0;
256
+ const read = opts.readTailImpl ?? readTail;
257
+ const tail = read(join(opts.home, ".outerlayer", "spool", "events.jsonl"), SESSION_DETECT_TAIL_BYTES);
258
+ if (!tail || tail.data.length === 0) return void 0;
259
+ let lines = tail.data.toString("utf8").split("\n");
260
+ if (tail.truncated) lines = lines.slice(1);
261
+ const records = [];
262
+ for (const line of lines) {
263
+ const text = line.trim();
264
+ if (!text) continue;
265
+ try {
266
+ records.push(JSON.parse(text));
267
+ } catch {
268
+ continue;
269
+ }
270
+ }
271
+ const ended = /* @__PURE__ */ new Set();
272
+ for (const record of records) {
273
+ if (record.event === "SessionEnd" && typeof record.sessionId === "string" && record.sessionId !== "") {
274
+ ended.add(record.sessionId);
275
+ }
276
+ }
277
+ let newest;
278
+ for (const record of records) {
279
+ if (typeof record.sessionId !== "string" || record.sessionId === "") continue;
280
+ if (record.event === "SessionEnd" || ended.has(record.sessionId)) continue;
281
+ if (typeof record.cwd !== "string" || !opts.cwds.includes(record.cwd)) continue;
282
+ if (typeof record.t !== "string") continue;
283
+ const tMs = Date.parse(record.t);
284
+ if (!Number.isFinite(tMs) || opts.nowMs - tMs > SESSION_DETECT_MAX_AGE_MS) continue;
285
+ newest = record.sessionId;
286
+ }
287
+ return newest;
288
+ }
289
+
290
+ // src/launch-filter.ts
291
+ function launchRefusalForSession(sessionId, home) {
292
+ const spool = readLaunchSpoolState(home);
293
+ if (spool.ids.has(sessionId)) return void 0;
294
+ if (spool.unreadable !== void 0) {
295
+ return `the launch spool at ${launchesPath(home)} could not be read (${spool.unreadable}), so this session's launch could not be checked and nothing it produces may leave the machine. Fix the file's permissions and re-run.`;
296
+ }
297
+ if (spool.malformedLines !== void 0) {
298
+ return `the launch spool at ${launchesPath(home)} has ${spool.malformedLines} damaged line(s), so this session's launch could not be checked and nothing it produces may leave the machine.`;
299
+ }
300
+ return `this session was not launched with OUTERLAYER_WORK naming a work item, so nothing it produces may leave the machine. Start the session with OUTERLAYER_WORK set (for example OUTERLAYER_WORK=42), or run this from CI.`;
301
+ }
302
+ var LaunchFilter = class {
303
+ constructor(launchedSessionIds) {
304
+ this.launchedSessionIds = launchedSessionIds;
305
+ /** Sessions dropped for carrying no launch record — for the sync summary. */
306
+ this.skippedTotal = 0;
307
+ }
308
+ /** True when the session should sync. Records skips for reporting. */
309
+ allows(session) {
310
+ const key = session.subagent?.parentSessionId ?? session.id;
311
+ const allowed = typeof key === "string" && this.launchedSessionIds.has(key);
312
+ if (!allowed) this.skippedTotal += 1;
313
+ return allowed;
314
+ }
315
+ };
316
+ var HOOK_TIMEOUT_MS = 6e5;
317
+ var HOOK_INCOMPLETE_SLACK_MS = 6e4;
318
+ var HOOK_INCOMPLETE_AFTER_MS = HOOK_TIMEOUT_MS + HOOK_INCOMPLETE_SLACK_MS;
319
+ function formatDurationApprox(ms) {
320
+ const totalSeconds = Math.floor(ms / 1e3);
321
+ if (totalSeconds < 60) return `${totalSeconds}s`;
322
+ const minutes = Math.floor(totalSeconds / 60);
323
+ const seconds = totalSeconds % 60;
324
+ return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
325
+ }
326
+ function deriveHookOutcome(group, opts) {
327
+ const started = group.started;
328
+ if (!started || !started.sessionId || !started.hookEvent) return void 0;
329
+ const base = {
330
+ sessionId: started.sessionId,
331
+ hookEvent: started.hookEvent,
332
+ toolUseId: started.toolUseId ?? void 0,
333
+ command: started.cmd,
334
+ ts: started.t
335
+ };
336
+ if (group.finished) {
337
+ const exitCode = group.finished.exitCode;
338
+ return {
339
+ ...base,
340
+ durationMs: group.finished.durationMs,
341
+ ...exitCode !== null ? { exitCode } : {},
342
+ status: exitCode === 0 ? "ok" : "error"
343
+ };
344
+ }
345
+ if (group.aborted) {
346
+ return {
347
+ ...base,
348
+ durationMs: group.aborted.durationMs,
349
+ status: "aborted"
350
+ };
351
+ }
352
+ const startedAtMs = Date.parse(started.t);
353
+ const ageMs = opts.nowMs - startedAtMs;
354
+ if (!opts.sessionEnded && ageMs < HOOK_INCOMPLETE_AFTER_MS) return "defer";
355
+ if (opts.spoolDamaged) return void 0;
356
+ return {
357
+ ...base,
358
+ durationMs: ageMs,
359
+ status: "incomplete",
360
+ statusMessage: `did not complete (killed or crashed) \u2014 ran \u2265 ${formatDurationApprox(ageMs)}`
361
+ };
362
+ }
363
+ function hookOutcomeToEvent(outcome) {
364
+ return {
365
+ type: EVENT_TYPES.hookExecuted,
366
+ ts: outcome.ts,
367
+ data: {
368
+ hookEvent: outcome.hookEvent,
369
+ hooks: [
370
+ {
371
+ command: outcome.command,
372
+ durationMs: outcome.durationMs,
373
+ ...outcome.exitCode !== void 0 ? { exitCode: outcome.exitCode } : {},
374
+ status: outcome.status,
375
+ ...outcome.toolUseId ? { toolUseId: outcome.toolUseId } : {},
376
+ ...outcome.statusMessage ? { statusMessage: outcome.statusMessage } : {}
377
+ }
378
+ ]
379
+ }
380
+ };
381
+ }
382
+ function appendHookExecEvents(session, events) {
383
+ let nextSeq = session.events.reduce((max, e) => Math.max(max, e.seq), -1) + 1;
384
+ for (const event of events) session.events.push({ ...event, seq: nextSeq++ });
385
+ }
386
+ function hookExecWatermarkPath(home = homeFromEnv()) {
387
+ return join(spoolWatermarkDir(home), "hook-exec.watermark");
388
+ }
389
+ function spoolWatermarkDir(home) {
390
+ return join(home, ".outerlayer", "spool");
391
+ }
392
+ function readHookExecWatermark(home = homeFromEnv()) {
393
+ try {
394
+ const raw = readFileSync(hookExecWatermarkPath(home), "utf8").trim();
395
+ const n = parseInt(raw, 10);
396
+ return Number.isFinite(n) && n >= 0 ? n : 0;
397
+ } catch {
398
+ return 0;
399
+ }
400
+ }
401
+ function writeHookExecWatermark(offset, home = homeFromEnv()) {
402
+ try {
403
+ const path = hookExecWatermarkPath(home);
404
+ mkdirSync(dirname(path), { recursive: true });
405
+ const tmp = `${path}.tmp`;
406
+ writeFileSync(tmp, String(offset));
407
+ renameSync(tmp, path);
408
+ } catch {
409
+ }
410
+ }
411
+ function readSpoolLinesSince(home, sinceOffset) {
412
+ let buf;
413
+ try {
414
+ buf = readFileSync(hookExecSpoolPath(home));
415
+ } catch {
416
+ return { lines: [], consumableEnd: sinceOffset, damagedLines: 0 };
417
+ }
418
+ const lines = [];
419
+ let damagedLines = 0;
420
+ const start = Math.min(Math.max(sinceOffset, 0), buf.length);
421
+ let pos = start;
422
+ let consumableEnd = start;
423
+ while (pos < buf.length) {
424
+ const nl = buf.indexOf(10, pos);
425
+ if (nl === -1) break;
426
+ const text = buf.subarray(pos, nl).toString("utf8").trim();
427
+ if (text) {
428
+ try {
429
+ lines.push({ record: JSON.parse(text), startOffset: pos });
430
+ } catch {
431
+ damagedLines += 1;
432
+ }
433
+ }
434
+ pos = nl + 1;
435
+ consumableEnd = pos;
436
+ }
437
+ return { lines, consumableEnd, damagedLines };
438
+ }
439
+ function groupByExecId(lines) {
440
+ const groups = /* @__PURE__ */ new Map();
441
+ for (const { record, startOffset } of lines) {
442
+ const g = groups.get(record.execId) ?? { startOffset };
443
+ if (record.rec === "started") {
444
+ g.started = record;
445
+ g.startOffset = startOffset;
446
+ } else if (record.rec === "finished") {
447
+ g.finished = record;
448
+ } else if (record.rec === "aborted") {
449
+ g.aborted = record;
450
+ }
451
+ groups.set(record.execId, g);
452
+ }
453
+ return groups;
454
+ }
455
+ function planHookExecMerge(opts = {}) {
456
+ const home = opts.home ?? homeFromEnv();
457
+ const nowMs = opts.nowMs ?? Date.now();
458
+ const sessionEnded = opts.sessionEnded ?? (() => false);
459
+ const sinceOffset = opts.fromOffset ?? readHookExecWatermark(home);
460
+ const { lines, consumableEnd, damagedLines } = readSpoolLinesSince(home, sinceOffset);
461
+ const groups = groupByExecId(lines);
462
+ const eventsBySession = /* @__PURE__ */ new Map();
463
+ const sessionGroupOffsets = /* @__PURE__ */ new Map();
464
+ const sessionNewestRecordMs = /* @__PURE__ */ new Map();
465
+ let fullyConsumedOffset = consumableEnd;
466
+ for (const group of groups.values()) {
467
+ const outcome = deriveHookOutcome(group, {
468
+ sessionEnded: group.started?.sessionId ? sessionEnded(group.started.sessionId) : false,
469
+ nowMs,
470
+ spoolDamaged: damagedLines > 0
471
+ });
472
+ if (outcome === "defer") {
473
+ fullyConsumedOffset = Math.min(fullyConsumedOffset, group.startOffset);
474
+ continue;
475
+ }
476
+ if (!outcome) continue;
477
+ const list = eventsBySession.get(outcome.sessionId) ?? [];
478
+ list.push(hookOutcomeToEvent(outcome));
479
+ eventsBySession.set(outcome.sessionId, list);
480
+ const startedMs = Date.parse(outcome.ts);
481
+ if (Number.isFinite(startedMs)) {
482
+ sessionNewestRecordMs.set(outcome.sessionId, Math.max(sessionNewestRecordMs.get(outcome.sessionId) ?? 0, startedMs));
483
+ }
484
+ const prior = sessionGroupOffsets.get(outcome.sessionId);
485
+ sessionGroupOffsets.set(outcome.sessionId, prior === void 0 ? group.startOffset : Math.min(prior, group.startOffset));
486
+ }
487
+ return {
488
+ eventsBySession,
489
+ sessionGroupOffsets,
490
+ sessionNewestRecordMs,
491
+ fullyConsumedOffset,
492
+ recordsRead: lines.length,
493
+ damagedLines
494
+ };
495
+ }
496
+ var HOOK_EXEC_SPOOL_RETENTION_MS = 14 * 24 * 60 * 60 * 1e3;
497
+ function spoolLineExpired(text, nowMs, retentionMs) {
498
+ let record;
499
+ try {
500
+ record = JSON.parse(text);
501
+ } catch {
502
+ return void 0;
503
+ }
504
+ if (record.rec !== "started") return void 0;
505
+ const startedMs = Date.parse(record.t);
506
+ if (!Number.isFinite(startedMs)) return false;
507
+ return nowMs - startedMs > retentionMs;
508
+ }
509
+ function pruneHookExecSpool(opts) {
510
+ const home = opts.home ?? homeFromEnv();
511
+ const nowMs = opts.nowMs ?? Date.now();
512
+ const retentionMs = opts.retentionMs ?? HOOK_EXEC_SPOOL_RETENTION_MS;
513
+ const path = hookExecSpoolPath(home);
514
+ let buf;
515
+ try {
516
+ buf = readFileSync(path);
517
+ } catch {
518
+ return 0;
519
+ }
520
+ const limit = Math.min(Math.max(opts.consumedOffset, 0), buf.length);
521
+ let dropTo = 0;
522
+ let pos = 0;
523
+ while (pos < limit) {
524
+ const nl = buf.indexOf(10, pos);
525
+ if (nl === -1 || nl + 1 > limit) break;
526
+ const text = buf.subarray(pos, nl).toString("utf8").trim();
527
+ if (text && spoolLineExpired(text, nowMs, retentionMs) === false) break;
528
+ pos = nl + 1;
529
+ dropTo = pos;
530
+ }
531
+ if (dropTo === 0) return 0;
532
+ const tmp = `${path}.prune`;
533
+ try {
534
+ writeFileSync(tmp, buf.subarray(dropTo));
535
+ opts.onSnapshot?.();
536
+ if (statSync(path).size !== buf.length) {
537
+ rmSync(tmp);
538
+ return 0;
539
+ }
540
+ writeHookExecWatermark(Math.max(opts.consumedOffset - dropTo, 0), home);
541
+ renameSync(tmp, path);
542
+ } catch {
543
+ try {
544
+ rmSync(tmp);
545
+ } catch {
546
+ }
547
+ return 0;
548
+ }
549
+ return dropTo;
550
+ }
551
+
552
+ // src/live-upload.ts
553
+ var DEFAULT_DEBOUNCE_MS = 2e3;
554
+ var RETRY_BASE_DELAY_MS = 2e3;
555
+ var RETRY_MAX_DELAY_MS = 6e4;
556
+ function liveUploadStatePath(home) {
557
+ return join(home, ".outerlayer", "spool", "live-uploads.json");
558
+ }
559
+ function readLiveUploadState(home) {
560
+ try {
561
+ const parsed = JSON.parse(readFileSync(liveUploadStatePath(home), "utf8"));
562
+ if (parsed && typeof parsed === "object" && parsed.v === 1) {
563
+ const sessions = parsed.sessions;
564
+ return { v: 1, sessions: sessions && typeof sessions === "object" ? sessions : {} };
565
+ }
566
+ } catch {
567
+ }
568
+ return { v: 1, sessions: {} };
569
+ }
570
+ function writeLiveUploadState(state, home) {
571
+ try {
572
+ const path = liveUploadStatePath(home);
573
+ mkdirSync(join(home, ".outerlayer", "spool"), { recursive: true });
574
+ const tmp = `${path}.tmp-${process.pid}`;
575
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
576
+ renameSync(tmp, path);
577
+ } catch {
578
+ }
579
+ }
580
+ function subagentInfo(mirrorPath) {
581
+ const segments = mirrorPath.split(/[/\\]/);
582
+ const subagentsIdx = segments.lastIndexOf("subagents");
583
+ if (subagentsIdx <= 0) return { isSubagent: false };
584
+ return {
585
+ isSubagent: true,
586
+ parentSessionId: segments[subagentsIdx - 1] || void 0,
587
+ ownId: sessionIdFromPath(mirrorPath)
588
+ };
589
+ }
590
+ function sessionKeyFor(mirrorPath) {
591
+ return sessionIdFromPath(mirrorPath);
592
+ }
593
+ function endSignalKeyFor(mirrorPath) {
594
+ return subagentInfo(mirrorPath).parentSessionId ?? sessionIdFromPath(mirrorPath);
595
+ }
596
+ function sessionHasEnded(home, sessionKey) {
597
+ let text;
598
+ try {
599
+ text = readFileSync(join(spoolDir(home), "events.jsonl"), "utf8");
600
+ } catch {
601
+ return false;
602
+ }
603
+ for (const rawLine of text.split("\n")) {
604
+ const trimmed = rawLine.trim();
605
+ if (!trimmed) continue;
606
+ let record;
607
+ try {
608
+ record = JSON.parse(trimmed);
609
+ } catch {
610
+ continue;
611
+ }
612
+ if (record.event === "SessionEnd" && record.sessionId === sessionKey) return true;
613
+ }
614
+ return false;
615
+ }
616
+ function groupTurns(turns, open, wholeCounts) {
617
+ const groups = [];
618
+ let current = [];
619
+ let currentBytes = 0;
620
+ for (const raw of turns) {
621
+ let turn = raw;
622
+ let bytes = JSON.stringify(turn).length + 1;
623
+ if (bytes > CHUNK_PART_TARGET_BYTES) {
624
+ turn = sliceTurnIo(raw);
625
+ bytes = JSON.stringify(turn).length + 1;
626
+ }
627
+ if (current.length > 0 && currentBytes + bytes > CHUNK_PART_TARGET_BYTES) {
628
+ groups.push(current);
629
+ current = [];
630
+ currentBytes = 0;
631
+ }
632
+ current.push(turn);
633
+ currentBytes += bytes;
634
+ }
635
+ if (current.length > 0) groups.push(current);
636
+ return groups.map((groupTurnsSlice, i) => {
637
+ const isLast = i === groups.length - 1;
638
+ const fromTurn = groupTurnsSlice[0].index;
639
+ const toTurn = groupTurnsSlice[groupTurnsSlice.length - 1].index;
640
+ return {
641
+ turns: groupTurnsSlice,
642
+ delta: {
643
+ fromTurn,
644
+ toTurn,
645
+ open: isLast ? open : true,
646
+ counts: wholeCounts
647
+ }
648
+ };
649
+ });
650
+ }
651
+ async function acceptedSessionIds(response) {
652
+ try {
653
+ const parsed = await response.json();
654
+ const accepted = parsed?.data?.accepted;
655
+ return Array.isArray(accepted) ? accepted : void 0;
656
+ } catch {
657
+ return void 0;
658
+ }
659
+ }
660
+ function retryAfterMs(response) {
661
+ const raw = response?.headers.get("retry-after");
662
+ return raw && /^\d+$/.test(raw) ? Number(raw) * 1e3 : 0;
663
+ }
664
+ var LiveUploader = class {
665
+ constructor(opts) {
666
+ this.cycles = /* @__PURE__ */ new Map();
667
+ /** Sessions this sender is done with: ended, or permanently rejected. The
668
+ * daemon keeps mirroring a transcript after its session closes, and every
669
+ * one of those events would otherwise open a fresh debounce cycle that
670
+ * re-reads the state file only to refuse the session again. */
671
+ this.finished = /* @__PURE__ */ new Set();
672
+ /** Set on a 401 — the key is unknown/expired/wrong app, which is true for
673
+ * every session, so the sender stops entirely for the rest of this
674
+ * process rather than retrying session by session. */
675
+ this.stopped = false;
676
+ this.home = opts.home;
677
+ this.fetchImpl = opts.fetchImpl ?? fetch;
678
+ this.sleepImpl = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
679
+ this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
680
+ this.env = opts.env ?? process.env;
681
+ this.stderr = opts.stderr ?? ((s) => void process.stderr.write(s));
682
+ }
683
+ /** Called for every daemon `mirrored` event. Cheap and synchronous — the
684
+ * actual read/send work happens on the debounced cycle. */
685
+ notify(mirrorPath) {
686
+ if (this.stopped) return;
687
+ const key = sessionKeyFor(mirrorPath);
688
+ if (this.finished.has(key)) return;
689
+ const existing = this.cycles.get(key);
690
+ if (existing) {
691
+ existing.latestFile = mirrorPath;
692
+ existing.generation += 1;
693
+ return;
694
+ }
695
+ const cycle = { latestFile: mirrorPath, generation: 0, loop: Promise.resolve() };
696
+ cycle.loop = this.runCycle(key, cycle).finally(() => {
697
+ if (this.cycles.get(key) === cycle) this.cycles.delete(key);
698
+ });
699
+ this.cycles.set(key, cycle);
700
+ }
701
+ /** Test-only: drains every debounce/retry cycle currently pending, with no
702
+ * real timers involved (the injected `sleepImpl` resolves however the
703
+ * test wants). Loops because a cycle's own outcome (a retry, or a fresh
704
+ * notify that arrived mid-send) can leave new work queued. */
705
+ async flush() {
706
+ while (this.cycles.size > 0) {
707
+ await Promise.allSettled([...this.cycles.values()].map((c) => c.loop));
708
+ }
709
+ }
710
+ /** Nothing awaits a cycle in the daemon — `notify()` starts it and returns
711
+ * — so an error that escaped here would be an unhandled rejection, and
712
+ * Node's default for those is to exit the process, taking the transcript
713
+ * mirroring down with the sender. A throw ends this session's cycle and
714
+ * nothing else; the next mirrored event for it starts a fresh one. */
715
+ async runCycle(key, cycle) {
716
+ try {
717
+ let processedGeneration = -1;
718
+ for (; ; ) {
719
+ for (; ; ) {
720
+ const generationAtWaitStart = cycle.generation;
721
+ await this.sleepImpl(this.debounceMs);
722
+ if (cycle.generation !== generationAtWaitStart) continue;
723
+ break;
724
+ }
725
+ if (cycle.generation === processedGeneration) return;
726
+ const generationAtProcess = cycle.generation;
727
+ const stop = await this.processOnce(key, cycle.latestFile);
728
+ if (stop) this.finished.add(key);
729
+ processedGeneration = generationAtProcess;
730
+ if (stop || cycle.generation === processedGeneration) return;
731
+ }
732
+ } catch {
733
+ }
734
+ }
735
+ /** One session's whole eligibility check + (if eligible) send, including
736
+ * any retries. Returns true when this session's cycle should stop for
737
+ * good (ended, permanently rejected, or the sender is globally stopped) —
738
+ * the caller records that, so no later mirrored event reopens a cycle for
739
+ * it. A hold that could lift on a later fire — no credentials yet,
740
+ * `autoSync` off, an undecidable launch spool, a misconfigured tier —
741
+ * returns false instead, because eligibility is re-checked on every fire. */
742
+ async processOnce(key, mirrorPath) {
743
+ const state = readLiveUploadState(this.home);
744
+ const priorEntry = state.sessions[key];
745
+ if (priorEntry?.ended || priorEntry?.failed) return true;
746
+ const cloudConfig = readCloudConfig(this.home);
747
+ if (!cloudConfig.url || !cloudConfig.apiKey || !cloudConfig.appId) return false;
748
+ if (cloudConfig.autoSync === false) return false;
749
+ const info = subagentInfo(mirrorPath);
750
+ const tail = tailTranscript(mirrorPath, void 0, {
751
+ isSubagent: info.isSubagent,
752
+ parentSessionId: info.parentSessionId,
753
+ ownId: info.ownId
754
+ });
755
+ if (!tail.session) return false;
756
+ const session = tail.session;
757
+ enrichSessionRepo(session);
758
+ const repoFilter = new RepoFilter(cloudConfig.repos);
759
+ if (!repoFilter.allows(session.env.gitRepo)) return false;
760
+ const launchSpool = readLaunchSpoolState(this.home);
761
+ if (!launchStatusIsDecisive(launchSpool)) return false;
762
+ const launchFilter = new LaunchFilter(launchSpool.ids);
763
+ if (!launchFilter.allows(session)) return false;
764
+ let tier;
765
+ try {
766
+ tier = resolveTier(cloudConfig.tier);
767
+ } catch (err) {
768
+ if (err instanceof SyncConfigError) return false;
769
+ throw err;
770
+ }
771
+ const hookEvents = planHookExecMerge({ home: this.home, fromOffset: 0 }).eventsBySession.get(session.id);
772
+ if (hookEvents && hookEvents.length > 0) appendHookExecEvents(session, hookEvents);
773
+ applyContextProvenance(session, readLatestProvenanceForSession(session.id, this.home));
774
+ installScrubContext(this.home, cloudConfig.scrub);
775
+ const prepared = prepareSessionForUpload(session, tier, this.env);
776
+ const ackedTurnIndex = priorEntry?.ackedTurnIndex ?? -1;
777
+ const fromTurn = ackedTurnIndex + 1;
778
+ const available = prepared.turns.filter((t) => t.index >= fromTurn);
779
+ const open = !sessionHasEnded(this.home, endSignalKeyFor(mirrorPath));
780
+ if (available.length === 0 && open) return false;
781
+ const wholeCounts = wholeSessionCounts(prepared.turns);
782
+ const groups = groupTurns(available, open, wholeCounts);
783
+ const emptyFinalTurn = Math.max(ackedTurnIndex, 0);
784
+ const toSend = groups.length > 0 ? groups : [{ turns: [], delta: { fromTurn: emptyFinalTurn, toTurn: emptyFinalTurn, open: false, counts: wholeCounts } }];
785
+ const endpoint = new URL("/v1/agents/sync", cloudConfig.url).toString();
786
+ let currentAcked = ackedTurnIndex;
787
+ for (const group of toSend) {
788
+ const payload = {
789
+ schemaVersion: 1,
790
+ sessions: [{ ...prepared, turns: group.turns, delta: group.delta }]
791
+ };
792
+ const outcome = await this.sendWithRetry(endpoint, cloudConfig.apiKey, cloudConfig.appId, prepared.id, payload);
793
+ if (outcome === "unauthorized") {
794
+ this.stopped = true;
795
+ this.stderr(
796
+ `live upload stopped: the API key was refused. No session streams from this daemon until it restarts with a valid key.
797
+ `
798
+ );
799
+ return true;
800
+ }
801
+ if (outcome === "rejected") {
802
+ const reason = "the server refused this delta permanently";
803
+ this.mergeSessionState(key, {
804
+ ackedTurnIndex: currentAcked,
805
+ failed: true,
806
+ lastError: reason,
807
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
808
+ });
809
+ this.stderr(`live upload stopped for session ${prepared.id}: ${reason}. The next \`outerlayer sync\` uploads it whole.
810
+ `);
811
+ return true;
812
+ }
813
+ currentAcked = group.delta.open ? Math.max(currentAcked, group.delta.toTurn - 1) : Math.max(currentAcked, group.delta.toTurn);
814
+ this.mergeSessionState(key, {
815
+ ackedTurnIndex: currentAcked,
816
+ ended: !group.delta.open,
817
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
818
+ });
819
+ }
820
+ return !open;
821
+ }
822
+ /** Writes one session's entry into the state file, re-reading it first.
823
+ * Every session on the machine shares this file and each cycle awaits the
824
+ * network between its read and its write, so a write built from a snapshot
825
+ * taken before the request would revert whatever another session recorded
826
+ * in the meantime — its acked index, its `ended`, or its `failed`. */
827
+ mergeSessionState(key, entry) {
828
+ const current = readLiveUploadState(this.home);
829
+ writeLiveUploadState({ v: 1, sessions: { ...current.sessions, [key]: entry } }, this.home);
830
+ }
831
+ /** Sends one request, retrying a network error or a retryable 5xx forever
832
+ * with exponential backoff (2s → 60s cap) — the daemon runs unattended, so
833
+ * there is no bounded attempt count. A 401 or a permanent 4xx/rejection
834
+ * returns immediately instead of retrying. */
835
+ async sendWithRetry(endpoint, apiKey, appId, sessionId, payload) {
836
+ const body = JSON.stringify(payload);
837
+ let delayMs = RETRY_BASE_DELAY_MS;
838
+ for (; ; ) {
839
+ let response;
840
+ try {
841
+ response = await this.fetchImpl(endpoint, {
842
+ method: "POST",
843
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}`, "x-outerlayer-app-id": appId },
844
+ body
845
+ });
846
+ } catch {
847
+ response = void 0;
848
+ }
849
+ if (response) {
850
+ if (response.status === 401) return "unauthorized";
851
+ if (response.ok) {
852
+ const accepted = await acceptedSessionIds(response);
853
+ if (accepted) return accepted.includes(sessionId) ? "accepted" : "rejected";
854
+ } else if (!RETRYABLE_STATUSES.has(response.status)) {
855
+ return "rejected";
856
+ }
857
+ }
858
+ await this.sleepImpl(Math.max(retryAfterMs(response), delayMs));
859
+ delayMs = Math.min(delayMs * 2, RETRY_MAX_DELAY_MS);
860
+ }
861
+ }
862
+ };
863
+ function artifactsSpoolPath(home = homeFromEnv()) {
864
+ return join(home, ".outerlayer", "spool", "artifacts.jsonl");
865
+ }
866
+ function artifactBlobsDir(home = homeFromEnv()) {
867
+ return join(home, ".outerlayer", "spool", "artifact-blobs");
868
+ }
869
+ function artifactsStatePath(home = homeFromEnv()) {
870
+ return join(home, ".outerlayer", "spool", "artifacts.state.json");
871
+ }
872
+ var ARTIFACT_RETRY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
873
+ function appendArtifactRecord(record, home = homeFromEnv()) {
874
+ const path = artifactsSpoolPath(home);
875
+ mkdirSync(dirname(path), { recursive: true });
876
+ appendFileSync(path, JSON.stringify(record) + "\n");
877
+ }
878
+ function appendArtifactTombstone(artifactId, home = homeFromEnv(), now = () => /* @__PURE__ */ new Date()) {
879
+ const path = artifactsSpoolPath(home);
880
+ mkdirSync(dirname(path), { recursive: true });
881
+ const record = { rec: "artifact-tombstone", artifactId, t: now().toISOString() };
882
+ appendFileSync(path, JSON.stringify(record) + "\n");
883
+ }
884
+ function walkArtifactSpoolLines(home, visit) {
885
+ let buf;
886
+ try {
887
+ buf = readFileSync(artifactsSpoolPath(home));
888
+ } catch {
889
+ return;
890
+ }
891
+ let pos = 0;
892
+ while (pos < buf.length) {
893
+ const nl = buf.indexOf(10, pos);
894
+ const textEnd = nl === -1 ? buf.length : nl;
895
+ const text = buf.subarray(pos, textEnd).toString("utf8").trim();
896
+ if (text) visit(text, pos);
897
+ if (nl === -1) break;
898
+ pos = nl + 1;
899
+ }
900
+ }
901
+ function isArtifactTombstoned(artifactId, home = homeFromEnv()) {
902
+ let found = false;
903
+ walkArtifactSpoolLines(home, (text) => {
904
+ if (found) return;
905
+ try {
906
+ const parsed = ArtifactTombstoneRecordSchema.safeParse(JSON.parse(text));
907
+ if (parsed.success && parsed.data.artifactId === artifactId) found = true;
908
+ } catch {
909
+ }
910
+ });
911
+ return found;
912
+ }
913
+ var KNOWN_SPOOL_RECORD_TYPES = /* @__PURE__ */ new Set(["artifact", "artifact-tombstone"]);
914
+ var SHA256_HEX = /^[0-9a-f]{64}$/;
915
+ var MAX_DISPLAYED_REC_TYPE_LENGTH = 100;
916
+ var CONTROL_CHAR_PATTERN = new RegExp(
917
+ `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
918
+ "g"
919
+ );
920
+ function sanitizeTerminal(text) {
921
+ return text.replace(CONTROL_CHAR_PATTERN, "");
922
+ }
923
+ function sanitizeRecType(rec) {
924
+ const stripped = sanitizeTerminal(rec);
925
+ return stripped.length > MAX_DISPLAYED_REC_TYPE_LENGTH ? `${stripped.slice(0, MAX_DISPLAYED_REC_TYPE_LENGTH)}\u2026` : stripped;
926
+ }
927
+ function readUnknownRecordEnvelope(json) {
928
+ if (typeof json !== "object" || json === null) return void 0;
929
+ const rec = json.rec;
930
+ const t = json.t;
931
+ if (typeof rec !== "string" || typeof t !== "string") return void 0;
932
+ const shaRaw = json.sha256;
933
+ const sha256 = typeof shaRaw === "string" && SHA256_HEX.test(shaRaw) ? shaRaw : void 0;
934
+ return { rec, t, sha256 };
935
+ }
936
+ function classifySpoolLine(text) {
937
+ let json;
938
+ try {
939
+ json = JSON.parse(text);
940
+ } catch {
941
+ return { kind: "skipped", reason: "not valid JSON" };
942
+ }
943
+ const tombstone = ArtifactTombstoneRecordSchema.safeParse(json);
944
+ if (tombstone.success) return { kind: "tombstone", artifactId: tombstone.data.artifactId };
945
+ const parsed = ArtifactSpoolRecordSchema.safeParse(json);
946
+ if (parsed.success) return { kind: "record", record: parsed.data };
947
+ const envelope = readUnknownRecordEnvelope(json);
948
+ if (envelope !== void 0 && !KNOWN_SPOOL_RECORD_TYPES.has(envelope.rec)) {
949
+ const sanitizedRecType = sanitizeRecType(envelope.rec);
950
+ const recType = sanitizedRecType === "" ? "(empty)" : `"${sanitizedRecType}"`;
951
+ return {
952
+ kind: "held",
953
+ reason: `unrecognized record type ${recType} \u2014 this build's schemas do not recognize it`,
954
+ ...envelope.sha256 !== void 0 ? { sha256: envelope.sha256 } : {}
955
+ };
956
+ }
957
+ const issue = parsed.error.issues[0];
958
+ const issueDetail = issue ? ` (${issue.path.length > 0 ? `${issue.path.join(".")}: ` : ""}${sanitizeTerminal(issue.message)})` : "";
959
+ return { kind: "skipped", reason: `does not match the artifact spool record shape${issueDetail}` };
960
+ }
961
+ function legacySidecarPaths(home) {
962
+ const dir = join(home, ".outerlayer", "spool");
963
+ return [
964
+ join(dir, "artifacts.watermark"),
965
+ join(dir, "artifacts.uploaded.json"),
966
+ join(dir, "artifacts.dropped.json"),
967
+ join(dir, "artifacts.held-offsets.json"),
968
+ join(dir, "artifacts.pending-first-seen.json")
969
+ ];
970
+ }
971
+ function readLegacySidecars(home) {
972
+ const dir = join(home, ".outerlayer", "spool");
973
+ let watermark = 0;
974
+ try {
975
+ const n = parseInt(readFileSync(join(dir, "artifacts.watermark"), "utf8").trim(), 10);
976
+ if (Number.isFinite(n) && n >= 0) watermark = n;
977
+ } catch {
978
+ }
979
+ const readIdSet = (name) => {
980
+ try {
981
+ const raw = JSON.parse(readFileSync(join(dir, name), "utf8"));
982
+ return new Set(Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : []);
983
+ } catch {
984
+ return /* @__PURE__ */ new Set();
985
+ }
986
+ };
987
+ let pendingFirstSeen = {};
988
+ try {
989
+ const raw = JSON.parse(readFileSync(join(dir, "artifacts.pending-first-seen.json"), "utf8"));
990
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
991
+ const out = {};
992
+ for (const [key, value] of Object.entries(raw)) {
993
+ if (typeof value === "string") out[key] = value;
994
+ }
995
+ pendingFirstSeen = out;
996
+ }
997
+ } catch {
998
+ }
999
+ return {
1000
+ watermark,
1001
+ uploadedIds: readIdSet("artifacts.uploaded.json"),
1002
+ droppedIds: readIdSet("artifacts.dropped.json"),
1003
+ pendingFirstSeen
1004
+ };
1005
+ }
1006
+ function computeMigratedState(home) {
1007
+ const legacy = readLegacySidecars(home);
1008
+ const state = /* @__PURE__ */ new Map();
1009
+ const tombstoned = /* @__PURE__ */ new Set();
1010
+ const rawRecords = [];
1011
+ walkArtifactSpoolLines(home, (text, offset) => {
1012
+ const classified = classifySpoolLine(text);
1013
+ if (classified.kind === "tombstone") {
1014
+ tombstoned.add(classified.artifactId);
1015
+ } else if (classified.kind === "record") {
1016
+ rawRecords.push({
1017
+ artifactId: classified.record.artifactId,
1018
+ offset,
1019
+ sha256: classified.record.sha256,
1020
+ t: classified.record.t
1021
+ });
1022
+ }
1023
+ });
1024
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1025
+ for (const { artifactId, offset, sha256, t } of rawRecords) {
1026
+ if (offset >= legacy.watermark) continue;
1027
+ if (legacy.uploadedIds.has(artifactId)) {
1028
+ state.set(artifactId, { state: "uploaded", attempts: 1, firstSeen: t || nowIso, sha256, reachedServer: true });
1029
+ continue;
1030
+ }
1031
+ if (legacy.droppedIds.has(artifactId)) {
1032
+ state.set(artifactId, {
1033
+ state: "dropped",
1034
+ attempts: 1,
1035
+ firstSeen: legacy.pendingFirstSeen[artifactId] ?? t ?? nowIso,
1036
+ sha256,
1037
+ reachedServer: false
1038
+ });
1039
+ continue;
1040
+ }
1041
+ if (tombstoned.has(artifactId)) continue;
1042
+ state.set(artifactId, { state: "dropped", attempts: 1, firstSeen: t || nowIso, sha256, reachedServer: true });
1043
+ }
1044
+ return state;
1045
+ }
1046
+ function readPersistedArtifactSpoolState(home) {
1047
+ let raw;
1048
+ try {
1049
+ raw = readFileSync(artifactsStatePath(home), "utf8");
1050
+ } catch {
1051
+ return { state: /* @__PURE__ */ new Map(), corrupt: false };
1052
+ }
1053
+ try {
1054
+ const parsed = JSON.parse(raw);
1055
+ const records = parsed !== null && typeof parsed === "object" && Array.isArray(parsed.records) ? parsed.records : void 0;
1056
+ if (records === void 0) return { state: /* @__PURE__ */ new Map(), corrupt: true };
1057
+ const state = /* @__PURE__ */ new Map();
1058
+ for (const entry of records) {
1059
+ if (entry === null || typeof entry !== "object") continue;
1060
+ const e = entry;
1061
+ if (typeof e.artifactId !== "string") continue;
1062
+ if (e.state !== "pending" && e.state !== "uploaded" && e.state !== "held" && e.state !== "dropped") continue;
1063
+ state.set(e.artifactId, {
1064
+ state: e.state,
1065
+ attempts: typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0,
1066
+ firstSeen: typeof e.firstSeen === "string" ? e.firstSeen : (/* @__PURE__ */ new Date(0)).toISOString(),
1067
+ sha256: typeof e.sha256 === "string" ? e.sha256 : "",
1068
+ reachedServer: e.reachedServer === true
1069
+ });
1070
+ }
1071
+ return { state, corrupt: false };
1072
+ } catch {
1073
+ return { state: /* @__PURE__ */ new Map(), corrupt: true };
1074
+ }
1075
+ }
1076
+ var ARTIFACT_STATE_RESOLVED_CAP = 5e3;
1077
+ function writeArtifactSpoolState(state, home = homeFromEnv()) {
1078
+ try {
1079
+ const path = artifactsStatePath(home);
1080
+ mkdirSync(dirname(path), { recursive: true });
1081
+ const tmp = `${path}.tmp`;
1082
+ const pending = [];
1083
+ const resolved = [];
1084
+ for (const pair of state.entries()) {
1085
+ (pair[1].state === "uploaded" || pair[1].state === "dropped" ? resolved : pending).push(pair);
1086
+ }
1087
+ const cappedResolved = resolved.length > ARTIFACT_STATE_RESOLVED_CAP ? resolved.slice(-ARTIFACT_STATE_RESOLVED_CAP) : resolved;
1088
+ const records = [...pending, ...cappedResolved].map(([artifactId, entry]) => ({ artifactId, ...entry }));
1089
+ writeFileSync(tmp, JSON.stringify({ version: 1, records }));
1090
+ renameSync(tmp, path);
1091
+ return true;
1092
+ } catch {
1093
+ return false;
1094
+ }
1095
+ }
1096
+ function migrateLegacyArtifactSidecars(home = homeFromEnv()) {
1097
+ if (existsSync(artifactsStatePath(home))) return;
1098
+ const paths = legacySidecarPaths(home);
1099
+ if (!paths.some((p) => existsSync(p))) return;
1100
+ writeArtifactSpoolState(computeMigratedState(home), home);
1101
+ for (const p of paths) {
1102
+ try {
1103
+ unlinkSync(p);
1104
+ } catch {
1105
+ }
1106
+ }
1107
+ }
1108
+ function readEffectiveArtifactSpoolState(home = homeFromEnv()) {
1109
+ const { state: persisted, corrupt } = existsSync(artifactsStatePath(home)) ? readPersistedArtifactSpoolState(home) : { state: computeMigratedState(home), corrupt: false };
1110
+ const state = new Map(persisted);
1111
+ const tombstonedIds = /* @__PURE__ */ new Set();
1112
+ walkArtifactSpoolLines(home, (text) => {
1113
+ const classified = classifySpoolLine(text);
1114
+ if (classified.kind === "tombstone") tombstonedIds.add(classified.artifactId);
1115
+ });
1116
+ if (tombstonedIds.size > 0) {
1117
+ walkArtifactSpoolLines(home, (text) => {
1118
+ const classified = classifySpoolLine(text);
1119
+ if (classified.kind !== "record") return;
1120
+ const { record } = classified;
1121
+ if (!tombstonedIds.has(record.artifactId)) return;
1122
+ const existing = state.get(record.artifactId);
1123
+ if (existing === void 0 || existing.state === "held" || existing.state === "pending") {
1124
+ state.set(record.artifactId, {
1125
+ state: "dropped",
1126
+ attempts: existing?.attempts ?? 0,
1127
+ firstSeen: existing?.firstSeen ?? record.t,
1128
+ sha256: record.sha256,
1129
+ reachedServer: false
1130
+ });
1131
+ }
1132
+ });
1133
+ }
1134
+ return { state, corrupt };
1135
+ }
1136
+ function planArtifactUpload(opts = {}) {
1137
+ const home = opts.home ?? homeFromEnv();
1138
+ const { state: effective, corrupt } = readEffectiveArtifactSpoolState(home);
1139
+ const state = new Map(effective);
1140
+ const candidates = [];
1141
+ const skipped = [];
1142
+ const held = [];
1143
+ const tombstonedIds = /* @__PURE__ */ new Set();
1144
+ walkArtifactSpoolLines(home, (text) => {
1145
+ const classified = classifySpoolLine(text);
1146
+ if (classified.kind === "tombstone") tombstonedIds.add(classified.artifactId);
1147
+ });
1148
+ walkArtifactSpoolLines(home, (text, offset) => {
1149
+ const classified = classifySpoolLine(text);
1150
+ if (classified.kind === "tombstone") return;
1151
+ if (classified.kind === "skipped") {
1152
+ skipped.push({ offset, reason: classified.reason });
1153
+ return;
1154
+ }
1155
+ if (classified.kind === "held") {
1156
+ held.push({ offset, reason: classified.reason, ...classified.sha256 !== void 0 ? { sha256: classified.sha256 } : {} });
1157
+ return;
1158
+ }
1159
+ const { record } = classified;
1160
+ if (tombstonedIds.has(record.artifactId)) return;
1161
+ const existing = state.get(record.artifactId);
1162
+ if (existing === void 0 || existing.state === "held" || existing.state === "pending") {
1163
+ candidates.push({ record });
1164
+ }
1165
+ });
1166
+ return { candidates, state, skipped, held, corrupt };
1167
+ }
1168
+ function neededArtifactShas(home, finalState) {
1169
+ const needed = /* @__PURE__ */ new Set();
1170
+ walkArtifactSpoolLines(home, (text) => {
1171
+ const classified = classifySpoolLine(text);
1172
+ if (classified.kind === "held") {
1173
+ if (classified.sha256 !== void 0) needed.add(classified.sha256);
1174
+ return;
1175
+ }
1176
+ if (classified.kind !== "record") return;
1177
+ const entry = finalState.get(classified.record.artifactId);
1178
+ if (entry === void 0 || entry.state === "held" || entry.state === "pending") needed.add(classified.record.sha256);
1179
+ });
1180
+ return needed;
1181
+ }
1182
+ function reclaimUnneededArtifactBlobs(home, neededShas) {
1183
+ let names;
1184
+ try {
1185
+ names = readdirSync(artifactBlobsDir(home));
1186
+ } catch {
1187
+ return 0;
1188
+ }
1189
+ let reclaimed = 0;
1190
+ for (const name of names) {
1191
+ if (!SHA256_HEX.test(name) || neededShas.has(name)) continue;
1192
+ try {
1193
+ unlinkSync(join(artifactBlobsDir(home), name));
1194
+ reclaimed += 1;
1195
+ } catch {
1196
+ }
1197
+ }
1198
+ return reclaimed;
1199
+ }
1200
+ function localArtifactIds(home = homeFromEnv()) {
1201
+ const ids = /* @__PURE__ */ new Set();
1202
+ walkArtifactSpoolLines(home, (text) => {
1203
+ const classified = classifySpoolLine(text);
1204
+ if (classified.kind === "record") ids.add(classified.record.artifactId);
1205
+ });
1206
+ return ids;
1207
+ }
1208
+ function findPendingArtifactRecord(artifactId, home = homeFromEnv()) {
1209
+ let found;
1210
+ walkArtifactSpoolLines(home, (text) => {
1211
+ if (found !== void 0) return;
1212
+ const classified = classifySpoolLine(text);
1213
+ if (classified.kind === "record" && classified.record.artifactId === artifactId) found = classified.record;
1214
+ });
1215
+ if (found === void 0) return void 0;
1216
+ const { state } = readEffectiveArtifactSpoolState(home);
1217
+ const entry = state.get(artifactId);
1218
+ if (entry !== void 0 && entry.state !== "held" && entry.state !== "pending") return void 0;
1219
+ return { record: found };
1220
+ }
1221
+ function resolveArtifactTurnIndex(turns, filename) {
1222
+ let found;
1223
+ for (const turn of turns) {
1224
+ for (const call of turn.toolCalls ?? []) {
1225
+ const haystack = [
1226
+ call.name,
1227
+ typeof call.input === "string" ? call.input : call.input === void 0 ? "" : JSON.stringify(call.input),
1228
+ typeof call.output === "string" ? call.output : call.output === void 0 ? "" : JSON.stringify(call.output),
1229
+ call.file ?? ""
1230
+ ].join("\n");
1231
+ if (haystack.includes("emit artifact") && haystack.includes(filename)) found = turn.index;
1232
+ }
1233
+ }
1234
+ return found;
1235
+ }
1236
+
1237
+ // src/skip-verdict.ts
1238
+ var RESUME_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
1239
+ function unlaunchedVerdict(opts) {
1240
+ if (!opts.launchStatusDecisive) return "unknown";
1241
+ return opts.idleMs < RESUME_WINDOW_MS ? "reversible" : "final";
1242
+ }
1243
+ function statusUnknown(verdict) {
1244
+ return verdict === "unknown";
1245
+ }
1246
+ function mightStillShip(verdict) {
1247
+ return verdict !== "final";
1248
+ }
1249
+ var MAX_SESSION_REPOS = 100;
1250
+ function sessionReposPath(home = homeFromEnv()) {
1251
+ return join(home, ".outerlayer", "spool", "session-repos.json");
1252
+ }
1253
+ function readSessionRepos(home = homeFromEnv()) {
1254
+ const path = sessionReposPath(home);
1255
+ if (!existsSync(path)) return [];
1256
+ try {
1257
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1258
+ if (!Array.isArray(parsed)) return [];
1259
+ return parsed.filter(
1260
+ (r) => typeof r === "object" && r !== null && typeof r.repoRoot === "string" && typeof r.gitRepo === "string" && typeof r.lastSeenAt === "string"
1261
+ );
1262
+ } catch {
1263
+ return [];
1264
+ }
1265
+ }
1266
+ function recordSessionRepos(observed, now = /* @__PURE__ */ new Date(), home = homeFromEnv()) {
1267
+ if (observed.length === 0) return;
1268
+ const byRoot = new Map(readSessionRepos(home).map((r) => [r.repoRoot, r]));
1269
+ const at = now.toISOString();
1270
+ for (const { repoRoot, gitRepo } of observed) {
1271
+ if (!repoRoot) continue;
1272
+ byRoot.set(repoRoot, { repoRoot, gitRepo, lastSeenAt: at });
1273
+ }
1274
+ const rows = [...byRoot.values()].sort((a, b) => a.lastSeenAt < b.lastSeenAt ? 1 : a.lastSeenAt > b.lastSeenAt ? -1 : 0).slice(0, MAX_SESSION_REPOS);
1275
+ try {
1276
+ const path = sessionReposPath(home);
1277
+ mkdirSync(dirname(path), { recursive: true });
1278
+ writeFileSync(path, JSON.stringify(rows, null, 2) + "\n");
1279
+ } catch {
1280
+ }
1281
+ }
1282
+
1283
+ // src/sync-cmd.ts
1284
+ function isLoopbackUrl(url) {
1285
+ try {
1286
+ const host = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, "");
1287
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "0.0.0.0" || /^127\./.test(host);
1288
+ } catch {
1289
+ return false;
1290
+ }
1291
+ }
1292
+ var MAX_SESSIONS_PER_BATCH = 25;
1293
+ var MAX_BLOBS_PER_BATCH = 64;
1294
+ var MAX_BLOB_BYTES_PER_BATCH = 4e6;
1295
+ var MAX_SINGLE_BLOB_BYTES = MAX_BLOB_BYTES_PER_BATCH;
1296
+ var MAX_BATCH_JSON_BYTES = 35e5;
1297
+ var SESSION_CHUNK_TRIGGER_BYTES = 1e6;
1298
+ var CHUNK_PART_TARGET_BYTES = 7e5;
1299
+ function blobsForTurns(blobs, turns) {
1300
+ const shas = /* @__PURE__ */ new Set();
1301
+ for (const t of turns) for (const img of t.images ?? []) shas.add(img.sha256);
1302
+ return blobs.filter((b) => shas.has(b.sha256));
1303
+ }
1304
+ function chunkOversizedSession(item) {
1305
+ if (JSON.stringify(item.session).length <= SESSION_CHUNK_TRIGGER_BYTES) return [item];
1306
+ const turns = item.session.turns ?? [];
1307
+ const counts = wholeSessionCounts(turns);
1308
+ const envelopeBytes = JSON.stringify({ ...item.session, turns: [] }).length;
1309
+ const budget = Math.max(CHUNK_PART_TARGET_BYTES - envelopeBytes, 1e5);
1310
+ const parts = [];
1311
+ let cur = [];
1312
+ let curBytes = 0;
1313
+ for (const raw of turns) {
1314
+ let turn = raw;
1315
+ let bytes = JSON.stringify(turn).length + 1;
1316
+ if (bytes > budget) {
1317
+ turn = sliceTurnIo(raw);
1318
+ bytes = JSON.stringify(turn).length + 1;
1319
+ }
1320
+ if (cur.length > 0 && curBytes + bytes > budget) {
1321
+ parts.push(cur);
1322
+ cur = [];
1323
+ curBytes = 0;
1324
+ }
1325
+ cur.push(turn);
1326
+ curBytes += bytes;
1327
+ }
1328
+ if (cur.length > 0) parts.push(cur);
1329
+ return parts.map((partTurns, i) => ({
1330
+ session: {
1331
+ ...item.session,
1332
+ turns: partTurns,
1333
+ chunk: { part: i + 1, of: parts.length, counts }
1334
+ },
1335
+ blobs: blobsForTurns(item.blobs, partTurns),
1336
+ mtimeMs: item.mtimeMs
1337
+ }));
1338
+ }
1339
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([500, 502, 503, 504]);
1340
+ var MAX_BATCH_ATTEMPTS = 6;
1341
+ var RETRY_BASE_DELAY_MS2 = 2e3;
1342
+ var KNOWN_PERMANENT_ARTIFACT_ERROR_CODES = /* @__PURE__ */ new Set([
1343
+ "invalid_request_body",
1344
+ "invalid_field_value",
1345
+ "session_not_found",
1346
+ "unknown_replaces_target",
1347
+ "nothing_to_attach",
1348
+ "artifact_content_conflict",
1349
+ "payload_too_large"
1350
+ ]);
1351
+ var NEVER_REACHED_SERVER_ERROR_CODES = /* @__PURE__ */ new Set([
1352
+ "invalid_request_body",
1353
+ "invalid_field_value",
1354
+ "session_not_found",
1355
+ "nothing_to_attach",
1356
+ "payload_too_large"
1357
+ ]);
1358
+ function splitPayload(payload) {
1359
+ const mid = Math.ceil(payload.sessions.length / 2);
1360
+ const first = payload.sessions.slice(0, mid);
1361
+ const second = payload.sessions.slice(mid);
1362
+ const shasOf = (sessions) => {
1363
+ const set = /* @__PURE__ */ new Set();
1364
+ for (const s of sessions) {
1365
+ const turns = s.turns ?? [];
1366
+ for (const t of turns) for (const img of t.images ?? []) if (typeof img.sha256 === "string") set.add(img.sha256);
1367
+ }
1368
+ return set;
1369
+ };
1370
+ const firstShas = shasOf(first);
1371
+ const secondShas = shasOf(second);
1372
+ const blobs = payload.blobs ?? [];
1373
+ const firstBlobs = blobs.filter((b) => firstShas.has(b.sha256) || !secondShas.has(b.sha256));
1374
+ const secondBlobs = blobs.filter((b) => secondShas.has(b.sha256));
1375
+ return [
1376
+ { schemaVersion: 1, sessions: first, ...firstBlobs.length > 0 ? { blobs: firstBlobs } : {} },
1377
+ { schemaVersion: 1, sessions: second, ...secondBlobs.length > 0 ? { blobs: secondBlobs } : {} }
1378
+ ];
1379
+ }
1380
+ var SyncConfigError = class extends Error {
1381
+ };
1382
+ var SyncTransportError = class extends Error {
1383
+ constructor(message, status) {
1384
+ super(message);
1385
+ this.status = status;
1386
+ this.name = "SyncTransportError";
1387
+ }
1388
+ };
1389
+ function resolveTier(raw) {
1390
+ if (raw === void 0) return "full";
1391
+ if (CAPTURE_TIERS.includes(raw)) return raw;
1392
+ throw new SyncConfigError(`unknown tier "${raw}" \u2014 use ${CAPTURE_TIERS.join(" | ")}`);
1393
+ }
1394
+ function entrySessionId(entry) {
1395
+ return entry.ownId ?? basename(entry.file).replace(/\.jsonl$/, "");
1396
+ }
1397
+ function sinceAdapters(sinceMs, alsoSessionIds = /* @__PURE__ */ new Set()) {
1398
+ return SOURCE_ADAPTERS.map((adapter) => ({
1399
+ ...adapter,
1400
+ discover: (roots) => {
1401
+ const entries = adapter.discover(roots);
1402
+ if (alsoSessionIds.size === 0) return entries.filter((entry) => entry.mtimeMs > sinceMs);
1403
+ return entries.filter((entry) => entry.mtimeMs > sinceMs || alsoSessionIds.has(entrySessionId(entry)));
1404
+ }
1405
+ }));
1406
+ }
1407
+ function fmtBytes(n) {
1408
+ if (n < 1024) return `${n} B`;
1409
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
1410
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
1411
+ }
1412
+ function fmtAge(ms) {
1413
+ if (!Number.isFinite(ms)) return "?";
1414
+ const minutes = Math.max(0, Math.floor(ms / 6e4));
1415
+ if (minutes < 60) return `${minutes}m`;
1416
+ const hours = Math.floor(minutes / 60);
1417
+ if (hours < 48) return `${hours}h`;
1418
+ return `${Math.floor(hours / 24)}d`;
1419
+ }
1420
+ async function runSync(opts = {}) {
1421
+ const refusal = staleBuildRefusal(opts.env ?? process.env);
1422
+ if (refusal) throw new SyncConfigError(refusal);
1423
+ const staleBuild = staleBuildWarning();
1424
+ if (staleBuild && !opts.quiet) process.stderr.write(`${C.yellow}!${C.reset} ${staleBuild}
1425
+ `);
1426
+ const home = opts.home ?? homeFromEnv(opts.env ?? process.env);
1427
+ const stats = {};
1428
+ try {
1429
+ const result = await runSyncInner(opts, stats);
1430
+ if (!result.dryRun) {
1431
+ writeSyncStatus(
1432
+ {
1433
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1434
+ ok: stats.blocked === void 0,
1435
+ contacted: stats.contacted === true,
1436
+ url: result.url,
1437
+ appId: stats.appId,
1438
+ synced: result.synced,
1439
+ pending: 0,
1440
+ ...stats.skippedNotLaunched ? { skippedNotLaunched: stats.skippedNotLaunched } : {},
1441
+ ...stats.held ? { held: stats.held } : {},
1442
+ ...stats.blocked !== void 0 ? { error: { message: stats.blocked } } : {}
1443
+ },
1444
+ home
1445
+ );
1446
+ }
1447
+ return result;
1448
+ } catch (err) {
1449
+ writeSyncStatus(
1450
+ {
1451
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1452
+ ok: false,
1453
+ url: stats.url,
1454
+ appId: stats.appId,
1455
+ synced: stats.synced ?? 0,
1456
+ pending: stats.pending,
1457
+ error: {
1458
+ status: err instanceof SyncTransportError ? err.status : void 0,
1459
+ message: err instanceof Error ? err.message : String(err)
1460
+ }
1461
+ },
1462
+ home
1463
+ );
1464
+ throw err;
1465
+ }
1466
+ }
1467
+ async function runSyncInner(opts, stats) {
1468
+ const envVars = opts.env ?? process.env;
1469
+ const home = opts.home ?? homeFromEnv(envVars);
1470
+ const fileConfig = readCloudConfig(home);
1471
+ const url = opts.url ?? envVars.OUTERLAYER_URL ?? fileConfig.url;
1472
+ const apiKey = opts.apiKey ?? envVars.OUTERLAYER_API_KEY ?? fileConfig.apiKey;
1473
+ const appId = opts.appId ?? envVars.OUTERLAYER_APP_ID ?? fileConfig.appId;
1474
+ stats.url = url;
1475
+ stats.appId = appId;
1476
+ const tier = resolveTier(opts.tier ?? envVars.OUTERLAYER_TIER ?? fileConfig.tier);
1477
+ const dryRun = opts.dryRun ?? false;
1478
+ const repoFilter = new RepoFilter(fileConfig.repos);
1479
+ const launchSpool = readLaunchSpoolState(home);
1480
+ const launchedSessionIds = launchSpool.ids;
1481
+ const launchFilter = new LaunchFilter(launchedSessionIds);
1482
+ const runNowMs = Date.now();
1483
+ const launchSkipVerdict = (mtimeMs) => unlaunchedVerdict({ launchStatusDecisive: launchStatusIsDecisive(launchSpool), idleMs: runNowMs - mtimeMs });
1484
+ const launchSkipReason = () => {
1485
+ if (launchSpool.unreadable !== void 0) return "the launch spool could not be read, so none could be checked";
1486
+ if (launchSpool.malformedLines !== void 0) {
1487
+ return `the launch spool has ${launchSpool.malformedLines} damaged line(s), so these could not be checked`;
1488
+ }
1489
+ return "not launched with OUTERLAYER_WORK";
1490
+ };
1491
+ const artifactHoldReason = (sessionId) => {
1492
+ if (launchedSessionIds.has(sessionId)) return void 0;
1493
+ if (launchSpool.unreadable !== void 0) {
1494
+ return "the launch spool could not be read this run, so no artifact may leave the machine";
1495
+ }
1496
+ if (launchSpool.malformedLines !== void 0) {
1497
+ return "the launch spool has damaged lines, so its session's launch could not be checked";
1498
+ }
1499
+ return "its session was not launched with OUTERLAYER_WORK, so it may not leave the machine";
1500
+ };
1501
+ if (launchSpool.unreadable !== void 0) {
1502
+ stats.blocked = `the launch spool at ${launchesPath(home)} could not be read (${launchSpool.unreadable}) \u2014 no session could be checked for a launch record, so nothing uploaded`;
1503
+ if (!opts.quiet) {
1504
+ process.stderr.write(
1505
+ `${C.yellow}!${C.reset} the launch spool at ${launchesPath(home)} could not be read (${launchSpool.unreadable}) \u2014 nothing uploads this run, and no session is passed over. Fix the file's permissions and sync again.
1506
+ `
1507
+ );
1508
+ }
1509
+ } else if (launchSpool.malformedLines !== void 0) {
1510
+ stats.blocked = `${launchSpool.malformedLines} line(s) in the launch spool at ${launchesPath(home)} could not be parsed \u2014 a session with no launch record cannot be told from one those lines named, so any such session is held instead of uploaded`;
1511
+ if (!opts.quiet)
1512
+ process.stderr.write(
1513
+ `${C.yellow}!${C.reset} ${launchSpool.malformedLines} line(s) in the launch spool at ${launchesPath(home)} could not be parsed, so a session with no launch record cannot be told from one those lines named. Nothing is passed over; sessions the spool does name still upload.
1514
+ `
1515
+ );
1516
+ }
1517
+ installScrubContext(home, fileConfig.scrub);
1518
+ if (!url || !apiKey || !appId) {
1519
+ const missing = [!url && "--url", !apiKey && "--api-key", !appId && "--app-id"].filter(Boolean).join(", ");
1520
+ throw new SyncConfigError(
1521
+ `missing ${missing}. Run \`outerlayer login --url <your gateway>\` and paste the key at the prompt, or set OUTERLAYER_URL / OUTERLAYER_API_KEY / OUTERLAYER_APP_ID. Mint a key in the dashboard: Settings \u2192 API keys.`
1522
+ );
1523
+ }
1524
+ {
1525
+ const sinceMs = opts.all ? 0 : readWatermark(url, appId, home);
1526
+ const hookPlan = planHookExecMerge({ home });
1527
+ const mergedHookEndKeys = /* @__PURE__ */ new Map();
1528
+ const hookRedeliverySessionIds = new Set(
1529
+ [...hookPlan.eventsBySession.keys()].filter((sessionId) => launchedSessionIds.has(sessionId))
1530
+ );
1531
+ if (hookPlan.damagedLines > 0 && !opts.quiet) {
1532
+ process.stderr.write(
1533
+ `${C.yellow}!${C.reset} ${hookPlan.damagedLines} line(s) in the hook-execution spool at ${hookExecSpoolPath(home)} could not be parsed \u2014 a hook that did not report an outcome is left unrecorded this run rather than reported as a crash.
1534
+ `
1535
+ );
1536
+ }
1537
+ if (!dryRun) migrateLegacyArtifactSidecars(home);
1538
+ const artifactPlan = planArtifactUpload({ home });
1539
+ if (!opts.quiet) {
1540
+ if (artifactPlan.corrupt) {
1541
+ process.stderr.write(
1542
+ `${C.yellow}!${C.reset} artifact spool state at ${artifactsStatePath(home)} is corrupt \u2014 treating it as empty; a record's saved disposition may be forgotten (a safe, if wasteful, re-upload for anything already synced)
1543
+ `
1544
+ );
1545
+ }
1546
+ for (const { offset, reason } of artifactPlan.skipped) {
1547
+ process.stderr.write(`${C.yellow}!${C.reset} artifact spool line at offset ${offset} skipped \u2014 ${reason}
1548
+ `);
1549
+ }
1550
+ for (const { offset, reason } of artifactPlan.held) {
1551
+ process.stderr.write(`${C.yellow}!${C.reset} artifact spool line at offset ${offset} held \u2014 ${reason}
1552
+ `);
1553
+ }
1554
+ }
1555
+ const combinedRecords = artifactPlan.candidates;
1556
+ const contextSourceBySessionId = /* @__PURE__ */ new Map();
1557
+ for (const record of readContextProvenance(home)) {
1558
+ contextSourceBySessionId.set(record.sessionId, record);
1559
+ }
1560
+ const liveUploads = readLiveUploadState(home).sessions;
1561
+ const collected = [];
1562
+ const observedRepos = /* @__PURE__ */ new Map();
1563
+ let heldMinMtimeMs = Number.POSITIVE_INFINITY;
1564
+ let passableMaxMtimeMs = 0;
1565
+ const neverShipSessionIds = /* @__PURE__ */ new Set();
1566
+ const noteSkipped = (sessionId, mtimeMs, verdict) => {
1567
+ if (statusUnknown(verdict)) heldMinMtimeMs = Math.min(heldMinMtimeMs, mtimeMs);
1568
+ else passableMaxMtimeMs = Math.max(passableMaxMtimeMs, mtimeMs);
1569
+ if (!mightStillShip(verdict) && typeof sessionId === "string") neverShipSessionIds.add(sessionId);
1570
+ };
1571
+ const { report } = scanAll({
1572
+ root: opts.root,
1573
+ rawRoot: opts.rawRoot,
1574
+ codexRoot: opts.codexRoot,
1575
+ cursorRoot: opts.cursorRoot,
1576
+ cursorProjectsRoot: opts.cursorProjectsRoot,
1577
+ limit: opts.limit,
1578
+ captureTier: tier,
1579
+ adapters: sinceAdapters(sinceMs, hookRedeliverySessionIds),
1580
+ onSession: (session, entry, blobs) => {
1581
+ enrichSessionRepo(session);
1582
+ setRepoRoot(resolveRepoIdentity(session.env.cwd).repoRoot);
1583
+ if (!repoFilter.allows(session.env?.gitRepo)) {
1584
+ noteSkipped(session.id, entry.mtimeMs, "reversible");
1585
+ return;
1586
+ }
1587
+ if (!launchFilter.allows(session)) {
1588
+ noteSkipped(session.id, entry.mtimeMs, launchSkipVerdict(entry.mtimeMs));
1589
+ return;
1590
+ }
1591
+ {
1592
+ const identity = resolveRepoIdentity(session.env.cwd);
1593
+ if (identity.repoRoot) {
1594
+ observedRepos.set(identity.repoRoot, identity.gitRepo ?? "");
1595
+ }
1596
+ }
1597
+ const hookEvents = hookPlan.eventsBySession.get(session.id);
1598
+ if (hookEvents && hookEvents.length > 0) {
1599
+ appendHookExecEvents(session, hookEvents);
1600
+ mergedHookEndKeys.set(session.id, session.subagent?.parentSessionId ?? session.id);
1601
+ }
1602
+ applyContextProvenance(session, contextSourceBySessionId.get(session.id));
1603
+ const gated = prepareSessionForUpload(session, tier, envVars);
1604
+ const liveEntry = liveUploads[gated.id];
1605
+ const daemonIsStreaming = liveEntry !== void 0 && !liveEntry.ended && !liveEntry.failed;
1606
+ if (daemonIsStreaming && !sessionHasEnded(home, gated.subagent?.parentSessionId ?? gated.id)) {
1607
+ const gatedTurns = gated.turns ?? [];
1608
+ gated.delta = {
1609
+ fromTurn: 0,
1610
+ toTurn: gatedTurns.reduce((max, t) => Math.max(max, t.index), 0),
1611
+ open: true,
1612
+ counts: wholeSessionCounts(gatedTurns)
1613
+ };
1614
+ }
1615
+ collected.push({
1616
+ session: gated,
1617
+ blobs: tier === "full" ? blobs : [],
1618
+ mtimeMs: entry.mtimeMs
1619
+ });
1620
+ }
1621
+ });
1622
+ if (report.droppedByLimitMinMtimeMs !== void 0) {
1623
+ noteSkipped(void 0, report.droppedByLimitMinMtimeMs, "unknown");
1624
+ }
1625
+ const launchDecisive = launchStatusIsDecisive(launchSpool);
1626
+ const worthHolding = (sessionId) => {
1627
+ if (!launchDecisive) return true;
1628
+ const newestRecordMs = hookPlan.sessionNewestRecordMs.get(sessionId);
1629
+ return newestRecordMs === void 0 || runNowMs - newestRecordMs < RESUME_WINDOW_MS;
1630
+ };
1631
+ const endedByKey = /* @__PURE__ */ new Map();
1632
+ const mergedSessionHasEnded = (sessionId) => {
1633
+ const key = mergedHookEndKeys.get(sessionId);
1634
+ if (key === void 0) return false;
1635
+ let ended = endedByKey.get(key);
1636
+ if (ended === void 0) {
1637
+ ended = sessionHasEnded(home, key);
1638
+ endedByKey.set(key, ended);
1639
+ }
1640
+ return ended;
1641
+ };
1642
+ let hookWatermarkOffset = hookPlan.fullyConsumedOffset;
1643
+ for (const [sessionId, offset] of hookPlan.sessionGroupOffsets) {
1644
+ if (mergedSessionHasEnded(sessionId)) continue;
1645
+ if (neverShipSessionIds.has(sessionId)) continue;
1646
+ if (!worthHolding(sessionId)) continue;
1647
+ hookWatermarkOffset = Math.min(hookWatermarkOffset, offset);
1648
+ }
1649
+ const watermarkCeilingMs = heldMinMtimeMs === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : heldMinMtimeMs - 1;
1650
+ stats.pending = collected.length;
1651
+ if (launchStatusIsDecisive(launchSpool)) stats.skippedNotLaunched = launchFilter.skippedTotal;
1652
+ else stats.held = launchFilter.skippedTotal;
1653
+ if (!opts.dryRun) {
1654
+ recordSessionRepos([...observedRepos].map(([repoRoot, gitRepo]) => ({ repoRoot, gitRepo })), /* @__PURE__ */ new Date(), home);
1655
+ for (const repoRoot of observedRepos.keys()) removeJournalHooks(repoRoot);
1656
+ }
1657
+ collected.sort((a, b) => a.mtimeMs - b.mtimeMs);
1658
+ const items = collected.flatMap((item) => chunkOversizedSession(item));
1659
+ const batches = [];
1660
+ {
1661
+ let current = { schemaVersion: 1, sessions: [] };
1662
+ let currentBlobs = /* @__PURE__ */ new Map();
1663
+ let currentBytes = 0;
1664
+ let currentBlobBytes = 0;
1665
+ let currentMax = 0;
1666
+ const flush = () => {
1667
+ if (current.sessions.length === 0) return;
1668
+ if (currentBlobs.size > 0) current.blobs = [...currentBlobs.values()];
1669
+ batches.push({ payload: current, maxMtimeMs: currentMax });
1670
+ current = { schemaVersion: 1, sessions: [] };
1671
+ currentBlobs = /* @__PURE__ */ new Map();
1672
+ currentBytes = 0;
1673
+ currentBlobBytes = 0;
1674
+ currentMax = 0;
1675
+ };
1676
+ for (const item of items) {
1677
+ const size = JSON.stringify(item.session).length + item.blobs.reduce((n, b) => n + b.data.length, 0);
1678
+ let newBlobs = item.blobs.filter((b) => !currentBlobs.has(b.sha256));
1679
+ const newBlobBytes = newBlobs.reduce((n, b) => n + b.data.length, 0);
1680
+ if (current.sessions.length >= MAX_SESSIONS_PER_BATCH || currentBytes > 0 && currentBytes + size > MAX_BATCH_JSON_BYTES || current.sessions.length > 0 && currentBlobs.size + newBlobs.length > MAX_BLOBS_PER_BATCH || current.sessions.length > 0 && currentBlobBytes + newBlobBytes > MAX_BLOB_BYTES_PER_BATCH) {
1681
+ flush();
1682
+ newBlobs = item.blobs.filter((b) => !currentBlobs.has(b.sha256));
1683
+ }
1684
+ const packed = [];
1685
+ let packedBytes = currentBlobBytes;
1686
+ for (const blob of newBlobs) {
1687
+ if (currentBlobs.size + packed.length >= MAX_BLOBS_PER_BATCH) break;
1688
+ if (blob.data.length >= MAX_SINGLE_BLOB_BYTES) continue;
1689
+ if (packedBytes + blob.data.length > MAX_BLOB_BYTES_PER_BATCH) continue;
1690
+ packed.push(blob);
1691
+ packedBytes += blob.data.length;
1692
+ }
1693
+ if (packed.length < newBlobs.length) {
1694
+ process.stderr.write(
1695
+ `${C.yellow}!${C.reset} session ${item.session.id ?? "?"} references ${newBlobs.length} images (${Math.round(newBlobBytes / 1024)} KB) \u2014 shipping ${packed.length}, the rest stay local
1696
+ `
1697
+ );
1698
+ }
1699
+ current.sessions.push(item.session);
1700
+ for (const blob of packed) if (!currentBlobs.has(blob.sha256)) currentBlobs.set(blob.sha256, blob);
1701
+ currentBytes += size;
1702
+ currentBlobBytes = packedBytes;
1703
+ currentMax = Math.max(currentMax, item.mtimeMs);
1704
+ }
1705
+ flush();
1706
+ }
1707
+ const blobsTotal = batches.reduce((n, b) => n + (b.payload.blobs?.length ?? 0), 0);
1708
+ const blobBytes = batches.reduce(
1709
+ (n, b) => n + (b.payload.blobs?.reduce((m, blob) => m + blob.bytes, 0) ?? 0),
1710
+ 0
1711
+ );
1712
+ if (dryRun) {
1713
+ let output2;
1714
+ if (opts.json) {
1715
+ output2 = JSON.stringify(
1716
+ {
1717
+ url,
1718
+ appId,
1719
+ tier,
1720
+ batches: batches.map((b) => b.payload),
1721
+ artifacts: {
1722
+ pending: combinedRecords.length,
1723
+ records: combinedRecords.map(({ record }) => {
1724
+ const holdReason = artifactHoldReason(record.sessionId);
1725
+ return {
1726
+ filename: record.filename,
1727
+ kind: inferArtifactKind(record.mediaType),
1728
+ sessionId: record.sessionId,
1729
+ emittedAt: record.t,
1730
+ ...holdReason !== void 0 ? { held: true, heldReason: holdReason } : {}
1731
+ };
1732
+ })
1733
+ }
1734
+ },
1735
+ null,
1736
+ 2
1737
+ );
1738
+ } else {
1739
+ const lines = [];
1740
+ lines.push(`${C.yellow}DRY RUN${C.reset} \u2014 nothing left this machine.`);
1741
+ lines.push("");
1742
+ lines.push(` destination ${url} ${C.dim}(app ${appId})${C.reset}`);
1743
+ lines.push(` tier ${tier}`);
1744
+ const stripped = bannedPathsForTier(tier);
1745
+ lines.push(
1746
+ stripped.length > 0 ? ` stripped ${C.dim}${stripped.join(", ")}${C.reset}` : ` stripped ${C.dim}nothing \u2014 full content ships${C.reset}`
1747
+ );
1748
+ lines.push(` sessions ${collected.length} (of ${report.scanned} scanned) in ${batches.length} batch(es)`);
1749
+ lines.push(` images ${blobsTotal} blob(s), ${fmtBytes(blobBytes)}`);
1750
+ if (combinedRecords.length > 0) {
1751
+ const heldRecords = combinedRecords.filter(({ record }) => artifactHoldReason(record.sessionId) !== void 0);
1752
+ lines.push(
1753
+ ` artifacts ${combinedRecords.length - heldRecords.length} pending upload` + (heldRecords.length > 0 ? `, ${heldRecords.length} held` : "")
1754
+ );
1755
+ for (const { record } of combinedRecords.slice(0, 10)) {
1756
+ const holdReason = artifactHoldReason(record.sessionId);
1757
+ lines.push(
1758
+ ` ${C.dim}${sanitizeTerminal(record.sessionId).slice(0, 8)}${C.reset} ${inferArtifactKind(record.mediaType)} ${sanitizeTerminal(record.filename)} ${C.dim}${fmtAge(Date.now() - Date.parse(record.t))}${C.reset}` + (holdReason !== void 0 ? ` ${C.yellow}held${C.reset} ${C.dim}\u2014 ${holdReason}${C.reset}` : "")
1759
+ );
1760
+ }
1761
+ if (combinedRecords.length > 10) {
1762
+ lines.push(` ${C.dim}\u2026 and ${combinedRecords.length - 10} more${C.reset}`);
1763
+ }
1764
+ }
1765
+ const byRepo = /* @__PURE__ */ new Map();
1766
+ for (const item of collected) {
1767
+ const r = item.session.env?.gitRepo || "(no repo)";
1768
+ byRepo.set(r, (byRepo.get(r) ?? 0) + 1);
1769
+ }
1770
+ const repoRows = [...byRepo.entries()].sort((a, b) => b[1] - a[1]);
1771
+ lines.push(` repos ${repoRows.length} distinct:`);
1772
+ for (const [r, n] of repoRows.slice(0, 15)) lines.push(` ${C.dim}${String(n).padStart(6)}${C.reset} ${r}`);
1773
+ if (repoRows.length > 15) lines.push(` ${C.dim}\u2026 and ${repoRows.length - 15} more${C.reset}`);
1774
+ if (repoFilter.skippedTotal > 0) {
1775
+ lines.push(` ${C.yellow}skipped${C.reset} ${repoFilter.skippedTotal} session(s) by repo filter:`);
1776
+ for (const [r, n] of [...repoFilter.skipped.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)) {
1777
+ lines.push(` ${C.dim}${String(n).padStart(6)}${C.reset} ${r}`);
1778
+ }
1779
+ }
1780
+ if (launchFilter.skippedTotal > 0) {
1781
+ lines.push(
1782
+ ` ${C.yellow}skipped${C.reset} ${launchFilter.skippedTotal} transcript(s) \u2014 ${launchSkipReason()}`
1783
+ );
1784
+ }
1785
+ lines.push("");
1786
+ for (const item of collected.slice(0, 20)) {
1787
+ const s = item.session;
1788
+ const repo = s.env?.gitRepo ? ` ${s.env.gitRepo}${s.env.gitBranch ? `@${s.env.gitBranch}` : ""}` : "";
1789
+ lines.push(
1790
+ ` ${C.dim}${s.id.slice(0, 8)}${C.reset} ${s.agent.type} ${s.startedAt ?? "?"} ${s.turns.length} turns${repo}`
1791
+ );
1792
+ }
1793
+ if (collected.length > 20) lines.push(` ${C.dim}\u2026 and ${collected.length - 20} more${C.reset}`);
1794
+ lines.push("");
1795
+ lines.push(`Run again without --dry-run to sync.`);
1796
+ output2 = lines.join("\n");
1797
+ }
1798
+ if (!opts.quiet) process.stdout.write(output2 + "\n");
1799
+ return {
1800
+ scanned: report.scanned,
1801
+ eligible: collected.length,
1802
+ synced: 0,
1803
+ rejected: [],
1804
+ // A dry run reads the spool (artifactPlan, above) to show what would
1805
+ // upload, but never ATTEMPTS an upload — and every drop reason
1806
+ // (missing blob, permanent 4xx, 14-day age-out) is discovered only
1807
+ // by attempting one, so nothing can be dropped this run either way.
1808
+ artifactsDropped: 0,
1809
+ blobsSent: 0,
1810
+ batches: batches.length,
1811
+ tier,
1812
+ url,
1813
+ dryRun: true,
1814
+ output: output2
1815
+ };
1816
+ }
1817
+ const fetchImpl = opts.fetchImpl ?? fetch;
1818
+ const endpoint = new URL("/v1/agents/sync", url).toString();
1819
+ let synced = 0;
1820
+ let blobsSent = 0;
1821
+ let artifactsDropped = 0;
1822
+ const rejected = [];
1823
+ let watermarkMs = sinceMs;
1824
+ let shippedBatches = 0;
1825
+ const progress = (final = false) => {
1826
+ if (opts.quiet || opts.json || batches.length <= 1) return;
1827
+ const msg = ` batch ${shippedBatches}/${batches.length} \xB7 ${synced} session(s) synced`;
1828
+ if (process.stderr.isTTY) {
1829
+ process.stderr.write(`\r${msg}${final ? "\n" : ""}`);
1830
+ } else if (final || shippedBatches % 25 === 0) {
1831
+ process.stderr.write(`${msg}
1832
+ `);
1833
+ }
1834
+ };
1835
+ const failureContext = () => {
1836
+ if (opts.quiet || shippedBatches === 0) return;
1837
+ process.stderr.write(
1838
+ `
1839
+ ${C.yellow}!${C.reset} progress saved: ${synced} session(s) in ${shippedBatches}/${batches.length} batch(es) shipped before the failure \u2014 re-run ${C.dim}outerlayer sync${C.reset} to resume from there.
1840
+ `
1841
+ );
1842
+ };
1843
+ const sleepImpl = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1844
+ const queue = batches.map(
1845
+ (b) => ({ payload: b.payload, maxMtimeMs: b.maxMtimeMs, advance: true })
1846
+ );
1847
+ let requestSessionCap = Number.POSITIVE_INFINITY;
1848
+ while (queue.length > 0) {
1849
+ const batch = queue.shift();
1850
+ if (batch.payload.sessions.length > requestSessionCap) {
1851
+ const [a, b] = splitPayload(batch.payload);
1852
+ queue.unshift(
1853
+ { payload: a, maxMtimeMs: batch.maxMtimeMs, advance: false },
1854
+ { payload: b, maxMtimeMs: batch.maxMtimeMs, advance: batch.advance }
1855
+ );
1856
+ continue;
1857
+ }
1858
+ let response;
1859
+ let networkErr = "";
1860
+ for (let attempt = 1; attempt <= MAX_BATCH_ATTEMPTS; attempt += 1) {
1861
+ try {
1862
+ response = await fetchImpl(endpoint, {
1863
+ method: "POST",
1864
+ headers: {
1865
+ "content-type": "application/json",
1866
+ authorization: `Bearer ${apiKey}`,
1867
+ "x-outerlayer-app-id": appId
1868
+ },
1869
+ body: JSON.stringify(batch.payload)
1870
+ });
1871
+ networkErr = "";
1872
+ stats.contacted = true;
1873
+ } catch (err) {
1874
+ response = void 0;
1875
+ networkErr = String(err);
1876
+ }
1877
+ if (response && !RETRYABLE_STATUSES.has(response.status)) break;
1878
+ if (attempt === MAX_BATCH_ATTEMPTS) break;
1879
+ const retryAfterRaw = response?.headers.get("retry-after");
1880
+ const retryAfterMs2 = retryAfterRaw && /^\d+$/.test(retryAfterRaw) ? Number(retryAfterRaw) * 1e3 : 0;
1881
+ const delayMs = Math.max(retryAfterMs2, RETRY_BASE_DELAY_MS2 * 2 ** (attempt - 1));
1882
+ if (!opts.quiet) {
1883
+ const cause = response ? `HTTP ${response.status}` : "network error";
1884
+ process.stderr.write(
1885
+ `
1886
+ ${C.yellow}!${C.reset} batch ${shippedBatches + 1}/${batches.length} hit ${cause} \u2014 retrying in ${Math.round(delayMs / 1e3)}s (attempt ${attempt}/${MAX_BATCH_ATTEMPTS})
1887
+ `
1888
+ );
1889
+ }
1890
+ await sleepImpl(delayMs);
1891
+ }
1892
+ if (!response) {
1893
+ failureContext();
1894
+ throw new SyncTransportError(`network error reaching ${endpoint}: ${networkErr}`);
1895
+ }
1896
+ if (response.status === 401) {
1897
+ failureContext();
1898
+ throw new SyncTransportError(
1899
+ "not authorized \u2014 the API key is unknown, expired, or bound to a different app. Mint a fresh key: dashboard \u2192 Settings \u2192 API keys.",
1900
+ 401
1901
+ );
1902
+ }
1903
+ if ((RETRYABLE_STATUSES.has(response.status) || response.status === 413) && batch.payload.sessions.length > 1) {
1904
+ const [a, b] = splitPayload(batch.payload);
1905
+ requestSessionCap = Math.min(requestSessionCap, Math.max(a.sessions.length, b.sessions.length));
1906
+ if (!opts.quiet) {
1907
+ process.stderr.write(
1908
+ `
1909
+ ${C.yellow}!${C.reset} batch keeps failing (HTTP ${response.status}) \u2014 splitting into ${a.sessions.length}+${b.sessions.length} session request(s)
1910
+ `
1911
+ );
1912
+ }
1913
+ queue.unshift(
1914
+ { payload: a, maxMtimeMs: batch.maxMtimeMs, advance: false },
1915
+ { payload: b, maxMtimeMs: batch.maxMtimeMs, advance: batch.advance }
1916
+ );
1917
+ continue;
1918
+ }
1919
+ if (!response.ok) {
1920
+ let detail = "";
1921
+ try {
1922
+ const text = await response.text();
1923
+ try {
1924
+ const body2 = JSON.parse(text);
1925
+ detail = body2.error?.message ?? "";
1926
+ const usage = body2.error?.details ?? body2.error;
1927
+ if (response.status === 429 && typeof usage?.currentCount === "number" && typeof usage?.limit === "number") {
1928
+ detail += ` (${usage.currentCount.toLocaleString()}/${usage.limit.toLocaleString()} units used this month)`;
1929
+ }
1930
+ } catch {
1931
+ detail = text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
1932
+ }
1933
+ } catch {
1934
+ }
1935
+ failureContext();
1936
+ throw new SyncTransportError(`sync failed (${response.status})${detail ? `: ${detail}` : ""}`, response.status);
1937
+ }
1938
+ const body = await response.json();
1939
+ const sentCount = batch.payload.sessions.length;
1940
+ const sessionRejectedCount = body.data.rejected.filter((r) => r.index >= 0).length;
1941
+ const accountedCount = body.data.accepted.length + sessionRejectedCount;
1942
+ if (accountedCount !== sentCount && !opts.quiet) {
1943
+ process.stderr.write(
1944
+ `
1945
+ ${C.yellow}!${C.reset} batch ${shippedBatches + 1}/${batches.length} accounting mismatch: sent ${sentCount}, accepted ${body.data.accepted.length}, rejected ${sessionRejectedCount}
1946
+ `
1947
+ );
1948
+ }
1949
+ synced += body.data.accepted.length;
1950
+ blobsSent += body.data.blobsStored;
1951
+ rejected.push(...body.data.rejected);
1952
+ stats.synced = synced;
1953
+ stats.pending = Math.max(0, collected.length - synced);
1954
+ if (batch.advance) {
1955
+ watermarkMs = Math.max(watermarkMs, Math.min(batch.maxMtimeMs, watermarkCeilingMs));
1956
+ writeWatermark(url, appId, watermarkMs, home);
1957
+ shippedBatches += 1;
1958
+ progress(shippedBatches === batches.length);
1959
+ }
1960
+ }
1961
+ const foldMs = Math.min(passableMaxMtimeMs, watermarkCeilingMs);
1962
+ if (foldMs > watermarkMs) {
1963
+ watermarkMs = foldMs;
1964
+ writeWatermark(url, appId, watermarkMs, home);
1965
+ }
1966
+ let artifactsUploaded;
1967
+ let artifactsHeld;
1968
+ if (combinedRecords.length > 0 || artifactPlan.held.length > 0) {
1969
+ const artifactEndpoint = new URL("/v1/artifacts", url).toString();
1970
+ const nowMs = Date.now();
1971
+ const state = artifactPlan.state;
1972
+ const failedOrDroppedThisRun = /* @__PURE__ */ new Set();
1973
+ let uploadedCount = 0;
1974
+ let heldCount = artifactPlan.held.length;
1975
+ let droppedCount = 0;
1976
+ const warn = (message) => {
1977
+ if (!opts.quiet) process.stderr.write(`${C.yellow}!${C.reset} ${message}
1978
+ `);
1979
+ };
1980
+ let unauthorized;
1981
+ for (const { record } of combinedRecords) {
1982
+ const name = sanitizeTerminal(record.filename);
1983
+ const holdReason = artifactHoldReason(record.sessionId);
1984
+ if (holdReason !== void 0) {
1985
+ heldCount += 1;
1986
+ warn(`artifact ${name}: held \u2014 ${holdReason}`);
1987
+ continue;
1988
+ }
1989
+ const existing = state.get(record.artifactId);
1990
+ const parsedEmittedMs = Date.parse(record.t);
1991
+ const emittedMsUsable = Number.isFinite(parsedEmittedMs) && parsedEmittedMs <= nowMs;
1992
+ const priorFirstSeenMs = existing !== void 0 ? Date.parse(existing.firstSeen) : NaN;
1993
+ const firstSeenMs = emittedMsUsable ? parsedEmittedMs : Number.isFinite(priorFirstSeenMs) ? priorFirstSeenMs : nowMs;
1994
+ const firstSeenIso = new Date(firstSeenMs).toISOString();
1995
+ if (unauthorized) {
1996
+ heldCount += 1;
1997
+ failedOrDroppedThisRun.add(record.artifactId);
1998
+ continue;
1999
+ }
2000
+ let blobData;
2001
+ try {
2002
+ blobData = readFileSync(join(artifactBlobsDir(home), record.sha256));
2003
+ } catch {
2004
+ state.set(record.artifactId, {
2005
+ state: "dropped",
2006
+ attempts: existing?.attempts ?? 0,
2007
+ firstSeen: firstSeenIso,
2008
+ sha256: record.sha256,
2009
+ reachedServer: true
2010
+ });
2011
+ failedOrDroppedThisRun.add(record.artifactId);
2012
+ warn(`artifact ${name}: blob missing from the spool \u2014 dropping the record`);
2013
+ droppedCount += 1;
2014
+ continue;
2015
+ }
2016
+ const owner = collected.find((c) => c.session.id === record.sessionId);
2017
+ const turnIndex = owner ? resolveArtifactTurnIndex(owner.session.turns ?? [], record.filename) : void 0;
2018
+ let replaces = record.replaces;
2019
+ if (record.replacesTargets === void 0 && replaces !== void 0) {
2020
+ const targetKnownUploaded = state.get(replaces)?.state === "uploaded";
2021
+ const targetTombstonedUnconfirmed = !targetKnownUploaded && isArtifactTombstoned(replaces, home);
2022
+ if (targetTombstonedUnconfirmed || failedOrDroppedThisRun.has(replaces)) replaces = void 0;
2023
+ }
2024
+ const request = {
2025
+ schemaVersion: 1,
2026
+ artifact: {
2027
+ clientArtifactId: record.artifactId,
2028
+ filename: record.filename,
2029
+ mediaType: record.mediaType,
2030
+ bytes: record.bytes,
2031
+ sha256: record.sha256,
2032
+ caption: record.caption,
2033
+ ...record.criterionId !== void 0 ? { criterionId: record.criterionId } : {},
2034
+ emittedAt: record.t,
2035
+ ...record.prNumber !== void 0 ? { prNumber: record.prNumber } : {},
2036
+ ...record.gitRepo !== void 0 ? { gitRepo: record.gitRepo } : {},
2037
+ ...record.gitBranch !== void 0 ? { gitBranch: record.gitBranch } : {},
2038
+ ...record.commitSha !== void 0 ? { commitSha: record.commitSha } : {},
2039
+ ...replaces !== void 0 ? { replaces } : {},
2040
+ ...record.replacesTargets !== void 0 ? { replacesTargets: record.replacesTargets } : {},
2041
+ session: { sessionId: record.sessionId, ...turnIndex !== void 0 ? { turnIndex } : {} }
2042
+ },
2043
+ blob: { data: blobData.toString("base64") }
2044
+ };
2045
+ let failure = "";
2046
+ let status;
2047
+ let errorCode = "";
2048
+ let serverMessage = "";
2049
+ try {
2050
+ const response = await fetchImpl(artifactEndpoint, {
2051
+ method: "POST",
2052
+ headers: {
2053
+ "content-type": "application/json",
2054
+ authorization: `Bearer ${apiKey}`,
2055
+ "x-outerlayer-app-id": appId
2056
+ },
2057
+ body: JSON.stringify(request)
2058
+ });
2059
+ if (!response.ok) {
2060
+ failure = `HTTP ${response.status}`;
2061
+ status = response.status;
2062
+ try {
2063
+ const body = JSON.parse(await response.text());
2064
+ if (typeof body.error?.code === "string") errorCode = body.error.code;
2065
+ if (typeof body.error?.message === "string") serverMessage = body.error.message;
2066
+ } catch {
2067
+ }
2068
+ }
2069
+ } catch (err) {
2070
+ failure = String(err);
2071
+ }
2072
+ if (!failure) {
2073
+ state.set(record.artifactId, {
2074
+ state: "uploaded",
2075
+ attempts: (existing?.attempts ?? 0) + 1,
2076
+ firstSeen: firstSeenIso,
2077
+ sha256: record.sha256,
2078
+ reachedServer: true
2079
+ });
2080
+ uploadedCount += 1;
2081
+ continue;
2082
+ }
2083
+ if (status === 401) {
2084
+ unauthorized = new SyncTransportError(
2085
+ "not authorized \u2014 the API key is unknown, expired, or bound to a different app. Mint a fresh key: dashboard \u2192 Settings \u2192 API keys.",
2086
+ 401
2087
+ );
2088
+ heldCount += 1;
2089
+ failedOrDroppedThisRun.add(record.artifactId);
2090
+ continue;
2091
+ }
2092
+ if (status !== void 0 && status >= 400 && status < 500 && status !== 408 && status !== 429 && KNOWN_PERMANENT_ARTIFACT_ERROR_CODES.has(errorCode)) {
2093
+ const detail = serverMessage ? `: ${sanitizeTerminal(serverMessage)}` : "";
2094
+ const hint = errorCode === "unknown_replaces_target" ? " \u2014 re-emit with a corrected --replaces id, or without --replaces" : "";
2095
+ warn(`artifact ${name} rejected permanently (${failure}${detail})${hint} \u2014 dropping the record`);
2096
+ state.set(record.artifactId, {
2097
+ state: "dropped",
2098
+ attempts: (existing?.attempts ?? 0) + 1,
2099
+ firstSeen: firstSeenIso,
2100
+ sha256: record.sha256,
2101
+ reachedServer: !NEVER_REACHED_SERVER_ERROR_CODES.has(errorCode)
2102
+ });
2103
+ failedOrDroppedThisRun.add(record.artifactId);
2104
+ droppedCount += 1;
2105
+ continue;
2106
+ }
2107
+ const ageMs = nowMs - firstSeenMs;
2108
+ if (ageMs > ARTIFACT_RETRY_MAX_AGE_MS) {
2109
+ warn(`artifact ${name} (${failure}) is older than 14 days and still failing \u2014 dropping it`);
2110
+ state.set(record.artifactId, {
2111
+ state: "dropped",
2112
+ attempts: (existing?.attempts ?? 0) + 1,
2113
+ firstSeen: firstSeenIso,
2114
+ sha256: record.sha256,
2115
+ reachedServer: false
2116
+ });
2117
+ failedOrDroppedThisRun.add(record.artifactId);
2118
+ droppedCount += 1;
2119
+ continue;
2120
+ }
2121
+ warn(`artifact ${name} upload failed (${failure}) \u2014 will retry on the next sync`);
2122
+ state.set(record.artifactId, {
2123
+ state: "held",
2124
+ attempts: (existing?.attempts ?? 0) + 1,
2125
+ firstSeen: firstSeenIso,
2126
+ sha256: record.sha256,
2127
+ reachedServer: false
2128
+ });
2129
+ failedOrDroppedThisRun.add(record.artifactId);
2130
+ heldCount += 1;
2131
+ }
2132
+ if (uploadedCount + heldCount + droppedCount > 0) {
2133
+ if (uploadedCount > 0) stats.contacted = true;
2134
+ artifactsUploaded = uploadedCount;
2135
+ artifactsHeld = heldCount;
2136
+ artifactsDropped = droppedCount;
2137
+ if (!opts.quiet) {
2138
+ process.stderr.write(
2139
+ `${C.dim}artifacts${C.reset} ${uploadedCount} uploaded \xB7 ${heldCount} held \xB7 ${droppedCount} dropped
2140
+ `
2141
+ );
2142
+ }
2143
+ }
2144
+ const persisted = writeArtifactSpoolState(state, home);
2145
+ if (!persisted && !opts.quiet) {
2146
+ process.stderr.write(
2147
+ `${C.yellow}!${C.reset} failed to persist ${artifactsStatePath(home)} \u2014 this run's artifact progress may be re-attempted next sync (safe: uploads are idempotent on the client artifact id)
2148
+ `
2149
+ );
2150
+ }
2151
+ reclaimUnneededArtifactBlobs(home, neededArtifactShas(home, state));
2152
+ if (unauthorized) {
2153
+ failureContext();
2154
+ throw unauthorized;
2155
+ }
2156
+ }
2157
+ if (shippedBatches === batches.length && (opts.url || opts.apiKey || opts.appId)) {
2158
+ if (isLoopbackUrl(url)) {
2159
+ if (!opts.quiet) {
2160
+ process.stderr.write(
2161
+ `${C.dim} not saving ${new URL(url).host} as the default destination \u2014 pass the flags again next time, or edit ${cloudConfigPath(home)}${C.reset}
2162
+ `
2163
+ );
2164
+ }
2165
+ } else {
2166
+ writeCloudConfig({ url, apiKey, appId }, home);
2167
+ }
2168
+ }
2169
+ let output;
2170
+ if (opts.json) {
2171
+ output = JSON.stringify({
2172
+ scanned: report.scanned,
2173
+ eligible: collected.length,
2174
+ synced,
2175
+ rejected,
2176
+ blobsSent,
2177
+ artifactsDropped,
2178
+ tier,
2179
+ url,
2180
+ ...artifactsUploaded !== void 0 ? { artifactsUploaded, artifactsHeld } : {}
2181
+ });
2182
+ } else {
2183
+ const lines = [];
2184
+ const icon = rejected.length === 0 && artifactsDropped === 0 && launchStatusIsDecisive(launchSpool) ? `${C.green}\u2713${C.reset}` : `${C.yellow}!${C.reset}`;
2185
+ lines.push(
2186
+ `${icon} synced ${synced}/${collected.length} session(s) \u2192 ${new URL(url).host} ${C.dim}(tier ${tier}${blobsSent ? `, ${blobsSent} image(s)` : ""}${repoFilter.skippedTotal > 0 ? `, ${repoFilter.skippedTotal} skipped by repo filter` : ""}${launchFilter.skippedTotal > 0 ? `, ${launchFilter.skippedTotal} skipped \u2014 ${launchSkipReason()}` : ""})${C.reset}`
2187
+ );
2188
+ if (artifactsDropped > 0) {
2189
+ lines.push(
2190
+ `${C.yellow} ${artifactsDropped} artifact(s) dropped \u2014 bytes missing from the spool, refused permanently, or too old to keep retrying, so the evidence they were emitted for is NOT attached${C.reset}`
2191
+ );
2192
+ }
2193
+ if (collected.length === 0) {
2194
+ lines.push(
2195
+ launchSpool.unreadable === void 0 ? `${C.dim} nothing new since the last sync \u2014 use --all to re-send everything${C.reset}` : `${C.dim} fix the launch spool's permissions and sync again \u2014 nothing was passed over${C.reset}`
2196
+ );
2197
+ }
2198
+ for (const r of rejected.slice(0, 10)) {
2199
+ lines.push(` ${C.red}\u2717${C.reset} ${r.id ?? `#${r.index}`} ${C.dim}${r.reason}${C.reset}`);
2200
+ }
2201
+ if (rejected.length > 10) lines.push(` ${C.dim}\u2026 and ${rejected.length - 10} more rejects${C.reset}`);
2202
+ output = lines.join("\n");
2203
+ }
2204
+ if (!opts.quiet) process.stdout.write(output + "\n");
2205
+ writeHookExecWatermark(hookWatermarkOffset, home);
2206
+ pruneHookExecSpool({ home, consumedOffset: hookWatermarkOffset });
2207
+ return {
2208
+ scanned: report.scanned,
2209
+ eligible: collected.length,
2210
+ synced,
2211
+ rejected,
2212
+ blobsSent,
2213
+ artifactsDropped,
2214
+ batches: batches.length,
2215
+ tier,
2216
+ url,
2217
+ dryRun: false,
2218
+ watermarkMs,
2219
+ ...artifactsUploaded !== void 0 ? { artifactsUploaded, artifactsHeld } : {},
2220
+ output
2221
+ };
2222
+ }
2223
+ }
2224
+
2225
+ export { CHUNK_PART_TARGET_BYTES, LiveUploader, RETRYABLE_STATUSES, RepoFilter, SyncConfigError, SyncTransportError, appendArtifactRecord, appendArtifactTombstone, artifactBlobsDir, artifactsSpoolPath, detectActiveSession, detectCi, findPendingArtifactRecord, isArtifactTombstoned, isLoopbackUrl, launchRefusalForSession, localArtifactIds, readEffectiveArtifactSpoolState, resolveTier, runSync, staleBuildRefusal, staleBuildWarning };