codiedev 0.9.18 → 0.9.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/captureLog.d.ts +20 -0
- package/dist/captureLog.js +101 -0
- package/dist/captureReadiness.d.ts +5 -0
- package/dist/captureReadiness.js +110 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/connect.js +4 -1
- package/dist/hook.js +144 -22
- package/dist/utils.d.ts +57 -0
- package/dist/utils.js +349 -10
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
export declare const CAPTURE_LOG_MAX_BYTES: number;
|
|
3
|
+
export declare const CAPTURE_LOG_PATH: string;
|
|
4
|
+
export interface CaptureLogEntry {
|
|
5
|
+
stage: "capture-finish" | "upload" | "upload-deferred" | "stderr";
|
|
6
|
+
sessionId: string | undefined;
|
|
7
|
+
outcome: string;
|
|
8
|
+
detail?: string;
|
|
9
|
+
}
|
|
10
|
+
interface LogDeps {
|
|
11
|
+
readFileSync: typeof fs.readFileSync;
|
|
12
|
+
writeFileSync: typeof fs.writeFileSync;
|
|
13
|
+
appendFileSync: typeof fs.appendFileSync;
|
|
14
|
+
existsSync: typeof fs.existsSync;
|
|
15
|
+
mkdirSync: typeof fs.mkdirSync;
|
|
16
|
+
statSync: typeof fs.statSync;
|
|
17
|
+
}
|
|
18
|
+
export declare function appendCaptureLog(entry: CaptureLogEntry, deps?: LogDeps, filePath?: string, now?: () => number): void;
|
|
19
|
+
export declare function readCaptureLogTail(n: number, deps?: Pick<LogDeps, "readFileSync" | "existsSync">, filePath?: string): string[];
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CAPTURE_LOG_PATH = exports.CAPTURE_LOG_MAX_BYTES = void 0;
|
|
37
|
+
exports.appendCaptureLog = appendCaptureLog;
|
|
38
|
+
exports.readCaptureLogTail = readCaptureLogTail;
|
|
39
|
+
// Rotating capture diagnostic log at ~/.codiedev/capture.log.
|
|
40
|
+
//
|
|
41
|
+
// Every stage of the capture pipeline (capture-finish child, upload child,
|
|
42
|
+
// stderr from detached children) writes one line here so users — and
|
|
43
|
+
// `codiedev doctor` — can see exactly why a capture succeeded or failed
|
|
44
|
+
// without having to file a support ticket or grep /tmp. The log is
|
|
45
|
+
// best-effort throughout: appendCaptureLog NEVER throws into the hook path.
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const os = __importStar(require("os"));
|
|
49
|
+
// Rotate the log when it exceeds this size (keeps the newest ~half). 256 KB
|
|
50
|
+
// holds thousands of entries; a user would have to see hundreds of errors
|
|
51
|
+
// before hitting this limit, so it's a generous safety valve.
|
|
52
|
+
exports.CAPTURE_LOG_MAX_BYTES = 256 * 1024;
|
|
53
|
+
exports.CAPTURE_LOG_PATH = path.join(os.homedir(), ".codiedev", "capture.log");
|
|
54
|
+
// Append one structured line to the capture log. Rotates the file when it
|
|
55
|
+
// grows past CAPTURE_LOG_MAX_BYTES by truncating it to the newest half (keeps
|
|
56
|
+
// the most recent diagnostics). Best-effort: NEVER throws into the hook path —
|
|
57
|
+
// a diagnostic log must not break the thing it's diagnosing.
|
|
58
|
+
function appendCaptureLog(entry, deps = fs, filePath = exports.CAPTURE_LOG_PATH, now = Date.now) {
|
|
59
|
+
try {
|
|
60
|
+
const dir = path.dirname(filePath);
|
|
61
|
+
if (!deps.existsSync(dir))
|
|
62
|
+
deps.mkdirSync(dir, { recursive: true });
|
|
63
|
+
if (deps.existsSync(filePath) && deps.statSync(filePath).size >= exports.CAPTURE_LOG_MAX_BYTES) {
|
|
64
|
+
// Rotation: keep the newest half. Slice from the middle to a line
|
|
65
|
+
// boundary so we never persist a torn (partial) first line.
|
|
66
|
+
const content = deps.readFileSync(filePath, "utf8");
|
|
67
|
+
const half = content.slice(Math.floor(content.length / 2));
|
|
68
|
+
const firstNewline = half.indexOf("\n");
|
|
69
|
+
// If no newline is found in the second half, drop it all and start
|
|
70
|
+
// fresh — better than keeping a torn line.
|
|
71
|
+
const fromLine = firstNewline === -1 ? "" : half.slice(firstNewline + 1);
|
|
72
|
+
deps.writeFileSync(filePath, fromLine);
|
|
73
|
+
}
|
|
74
|
+
// Sanitize before composing: error messages can carry newlines (e.g. a
|
|
75
|
+
// proxy's HTML error body in err.message) which would tear the one-line
|
|
76
|
+
// format that doctor's parser depends on.
|
|
77
|
+
const outcome = entry.outcome.replace(/\s+/g, " ").trim();
|
|
78
|
+
const detail = entry.detail?.replace(/\s+/g, " ").trim();
|
|
79
|
+
const line = `${new Date(now()).toISOString()} ${entry.stage} session=${entry.sessionId ?? "-"} ${outcome}` +
|
|
80
|
+
(detail ? ` ${detail}` : "") +
|
|
81
|
+
"\n";
|
|
82
|
+
deps.appendFileSync(filePath, line);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// diagnostics must never break capture
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Read the last N lines from the capture log. Returns [] when the file is
|
|
89
|
+
// missing or unreadable — callers (e.g. `codiedev doctor`) treat that as
|
|
90
|
+
// "nothing captured yet" rather than an error.
|
|
91
|
+
function readCaptureLogTail(n, deps = fs, filePath = exports.CAPTURE_LOG_PATH) {
|
|
92
|
+
try {
|
|
93
|
+
if (!deps.existsSync(filePath))
|
|
94
|
+
return [];
|
|
95
|
+
const lines = deps.readFileSync(filePath, "utf8").split("\n").filter(Boolean);
|
|
96
|
+
return lines.slice(-n);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -32,6 +32,11 @@ export interface LastCaptureSummary {
|
|
|
32
32
|
detail: string;
|
|
33
33
|
}
|
|
34
34
|
export declare function summarizeLastCapture(record: LastCaptureRecord | null, nowMs: number): LastCaptureSummary;
|
|
35
|
+
export interface CaptureActivitySummary {
|
|
36
|
+
status: "pass" | "warn" | "fail";
|
|
37
|
+
detail: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function summarizeCaptureActivity(tailLines: string[], pendingJobCount: number): CaptureActivitySummary;
|
|
35
40
|
export interface ServerCaptureResponse {
|
|
36
41
|
found: boolean;
|
|
37
42
|
latest: {
|
package/dist/captureReadiness.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.assessCaptureReadiness = assessCaptureReadiness;
|
|
|
11
11
|
exports.assessWorkspaceRepos = assessWorkspaceRepos;
|
|
12
12
|
exports.formatRelativeAge = formatRelativeAge;
|
|
13
13
|
exports.summarizeLastCapture = summarizeLastCapture;
|
|
14
|
+
exports.summarizeCaptureActivity = summarizeCaptureActivity;
|
|
14
15
|
exports.summarizeServerCapture = summarizeServerCapture;
|
|
15
16
|
function assessCaptureReadiness(cwd, repos, deps) {
|
|
16
17
|
let gitOnPath = false;
|
|
@@ -95,6 +96,115 @@ function summarizeLastCapture(record, nowMs) {
|
|
|
95
96
|
}
|
|
96
97
|
return { status: "pass", detail: `last session captured ${age} ago` };
|
|
97
98
|
}
|
|
99
|
+
// ─── Capture-activity summarizer ──────────────────────────────────────────────
|
|
100
|
+
// Turns the tail of ~/.codiedev/capture.log + a count of pending upload jobs
|
|
101
|
+
// into a single doctor CheckResult-compatible {status, detail} object.
|
|
102
|
+
//
|
|
103
|
+
// Pure (no fs/os imports) — all data is injected by the caller (doctor.ts).
|
|
104
|
+
const LOG_PATH_SUFFIX = "— details: ~/.codiedev/capture.log";
|
|
105
|
+
// Parse regex: a valid structured line has exactly this shape.
|
|
106
|
+
// stage is one of the four known stages; outcome is a bare token.
|
|
107
|
+
const STRUCTURED_LINE_RE = /^(\S+) (capture-finish|upload|upload-deferred|stderr) session=(\S+) (\S+)(?: (.*))?$/;
|
|
108
|
+
/** Append the shared log-path suffix to any detail string. */
|
|
109
|
+
function withSuffix(detail) {
|
|
110
|
+
return `${detail} ${LOG_PATH_SUFFIX}`;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Derive remediation text and status from the last failing upload-ish line.
|
|
114
|
+
* Inspects the `detail` field of the log line for http= and msg= tokens.
|
|
115
|
+
*/
|
|
116
|
+
function failDetail(rawLine, outcome, lineDetail) {
|
|
117
|
+
const d = lineDetail ?? "";
|
|
118
|
+
const truncated = rawLine.slice(0, 140);
|
|
119
|
+
let hint;
|
|
120
|
+
if (/http=40[13]/.test(d)) {
|
|
121
|
+
hint = `token invalid or expired — run \`codiedev connect\` with a fresh token`;
|
|
122
|
+
}
|
|
123
|
+
else if (/http=5/.test(d) || /fetch|network|ECONN|ETIMEDOUT/i.test(d)) {
|
|
124
|
+
hint = `backend unreachable — uploads are queued and retried automatically`;
|
|
125
|
+
}
|
|
126
|
+
else if (outcome === "gave-up") {
|
|
127
|
+
hint = `an upload exhausted its retries — check network/proxy, then run a new session`;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
hint = `see the log line for details`;
|
|
131
|
+
}
|
|
132
|
+
return withSuffix(`${hint} (${truncated})`);
|
|
133
|
+
}
|
|
134
|
+
function summarizeCaptureActivity(tailLines, pendingJobCount) {
|
|
135
|
+
const parsed = [];
|
|
136
|
+
for (const raw of tailLines) {
|
|
137
|
+
const m = STRUCTURED_LINE_RE.exec(raw);
|
|
138
|
+
if (!m)
|
|
139
|
+
continue;
|
|
140
|
+
parsed.push({ ts: m[1], stage: m[2], session: m[3], outcome: m[4], detail: m[5], raw });
|
|
141
|
+
}
|
|
142
|
+
// We care only about upload-ish lines (upload + upload-deferred).
|
|
143
|
+
// capture-finish and stderr lines are noise for this check.
|
|
144
|
+
const uploadLines = parsed.filter((p) => p.stage === "upload" || p.stage === "upload-deferred");
|
|
145
|
+
// Rule 1: last upload-ish outcome is ERROR or gave-up → fail.
|
|
146
|
+
if (uploadLines.length > 0) {
|
|
147
|
+
const last = uploadLines[uploadLines.length - 1];
|
|
148
|
+
if (last.outcome === "ERROR" || last.outcome === "gave-up") {
|
|
149
|
+
return {
|
|
150
|
+
status: "fail",
|
|
151
|
+
detail: failDetail(last.raw, last.outcome, last.detail),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Rule 2: pending jobs queued (last upload wasn't a hard failure, but
|
|
156
|
+
// retries are still in-flight).
|
|
157
|
+
if (pendingJobCount > 0) {
|
|
158
|
+
const noun = pendingJobCount === 1 ? "job" : "jobs";
|
|
159
|
+
return {
|
|
160
|
+
status: "warn",
|
|
161
|
+
detail: withSuffix(`${pendingJobCount} pending upload ${noun} queued for retry`),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Rule 3: no upload-ish lines at all — log is empty, garbage-only, or
|
|
165
|
+
// only capture-finish lines.
|
|
166
|
+
if (uploadLines.length === 0) {
|
|
167
|
+
return {
|
|
168
|
+
status: "warn",
|
|
169
|
+
detail: withSuffix("no capture uploads recorded yet on this machine — run a session and exit, then re-run `codiedev doctor`"),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// Rule 4a: last upload-ish outcome is ok → pass.
|
|
173
|
+
const last = uploadLines[uploadLines.length - 1];
|
|
174
|
+
const hasOk = uploadLines.some((p) => p.outcome === "ok");
|
|
175
|
+
if (last.outcome === "ok") {
|
|
176
|
+
return {
|
|
177
|
+
status: "pass",
|
|
178
|
+
detail: withSuffix(`last upload ok (${last.ts})`),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// Rule 4b: last is "deferred" — a throttled/scheduled defer was queued.
|
|
182
|
+
// If an ok exists somewhere in the tail → pass (next upload deferred is fine).
|
|
183
|
+
// If no ok exists → warn.
|
|
184
|
+
if (last.outcome === "deferred") {
|
|
185
|
+
if (hasOk) {
|
|
186
|
+
return {
|
|
187
|
+
status: "pass",
|
|
188
|
+
detail: withSuffix(`last upload ok; next upload deferred (throttle)`),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
status: "warn",
|
|
193
|
+
detail: withSuffix("deferred uploads were superseded but no completed upload found in the recent log"),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// Rule 4c: last is "superseded".
|
|
197
|
+
if (hasOk) {
|
|
198
|
+
return {
|
|
199
|
+
status: "pass",
|
|
200
|
+
detail: withSuffix(`last upload ok (a later upload superseded an earlier one)`),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
status: "warn",
|
|
205
|
+
detail: withSuffix("deferred uploads were superseded but no completed upload found in the recent log"),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
98
208
|
// Server-confirmed "did my last session land?". Prefers portal truth over the
|
|
99
209
|
// local 2xx breadcrumb; the highest-value branch is "uploaded locally but the
|
|
100
210
|
// portal has no record" — the exact silent failure devs hit (2xx to nothing,
|
package/dist/commands/doctor.js
CHANGED
|
@@ -44,6 +44,7 @@ const detection_1 = require("../detection");
|
|
|
44
44
|
const version_1 = require("../version");
|
|
45
45
|
const upgradeNudge_1 = require("../upgradeNudge");
|
|
46
46
|
const captureReadiness_1 = require("../captureReadiness");
|
|
47
|
+
const captureLog_1 = require("../captureLog");
|
|
47
48
|
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
48
49
|
const CLAUDE_INSTRUCTIONS_PATH = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
49
50
|
const CODEX_HOOKS_PATH = path.join(os.homedir(), ".codex", "hooks.json");
|
|
@@ -310,6 +311,23 @@ async function runDoctor(_args) {
|
|
|
310
311
|
status: lastCapture.status,
|
|
311
312
|
detail: lastCapture.detail,
|
|
312
313
|
});
|
|
314
|
+
// 4d. Capture-log activity: did recent uploads succeed? Are there pending jobs?
|
|
315
|
+
const logTail = (0, captureLog_1.readCaptureLogTail)(40);
|
|
316
|
+
let pendingJobCount = 0;
|
|
317
|
+
try {
|
|
318
|
+
pendingJobCount = fs
|
|
319
|
+
.readdirSync(os.tmpdir())
|
|
320
|
+
.filter((f) => /^codiedev-capture-.*\.json(\.claim-\d+)?$/.test(f)).length;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
pendingJobCount = 0;
|
|
324
|
+
}
|
|
325
|
+
const captureActivity = (0, captureReadiness_1.summarizeCaptureActivity)(logTail, pendingJobCount);
|
|
326
|
+
checks.push({
|
|
327
|
+
name: "Recent capture activity",
|
|
328
|
+
status: captureActivity.status,
|
|
329
|
+
detail: captureActivity.detail,
|
|
330
|
+
});
|
|
313
331
|
// 5. Claude Code setup (if present)
|
|
314
332
|
if ((0, utils_1.claudeCodeInstalled)()) {
|
|
315
333
|
checks.push({
|
package/dist/connect.js
CHANGED
|
@@ -42,7 +42,10 @@ const connectFlow_1 = require("./connectFlow");
|
|
|
42
42
|
const version_1 = require("./version");
|
|
43
43
|
const upgradeNudge_1 = require("./upgradeNudge");
|
|
44
44
|
const shared_1 = require("./commands/shared");
|
|
45
|
-
|
|
45
|
+
// cli.codiedev.com is a Convex custom domain on the prod deployment; the
|
|
46
|
+
// underlying judicious-falcon-861.convex.site URL keeps serving, so configs
|
|
47
|
+
// written by older CLI versions stay valid without a re-connect.
|
|
48
|
+
const BACKEND_URL = process.env.CODIEDEV_URL || "https://cli.codiedev.com";
|
|
46
49
|
/**
|
|
47
50
|
* Reads a line from stdin without echoing the typed/pasted characters —
|
|
48
51
|
* for tokens, passwords, anything that shouldn't end up in terminal
|
package/dist/hook.js
CHANGED
|
@@ -43,6 +43,7 @@ const child_process_1 = require("child_process");
|
|
|
43
43
|
const utils_1 = require("./utils");
|
|
44
44
|
const limitEvent_1 = require("./limitEvent");
|
|
45
45
|
const version_1 = require("./version");
|
|
46
|
+
const captureLog_1 = require("./captureLog");
|
|
46
47
|
function getJson(url, bearerToken) {
|
|
47
48
|
return new Promise((resolve, reject) => {
|
|
48
49
|
const parsed = new URL(url);
|
|
@@ -220,22 +221,49 @@ function resolveMode(arg) {
|
|
|
220
221
|
return "claude-code";
|
|
221
222
|
}
|
|
222
223
|
async function main() {
|
|
224
|
+
// Shared upload deps used by both "upload" and "upload-deferred" branches.
|
|
225
|
+
// Extracted to keep both branches in lockstep — any new dep added here
|
|
226
|
+
// automatically applies to the deferred path too.
|
|
227
|
+
function makeUploadDeps() {
|
|
228
|
+
return {
|
|
229
|
+
post: postJson,
|
|
230
|
+
readFileSync: fs.readFileSync,
|
|
231
|
+
unlinkSync: fs.unlinkSync,
|
|
232
|
+
// Persist a bumped attempt count on failure so a later sweep retries
|
|
233
|
+
// (instead of dropping a session on a transient network blip).
|
|
234
|
+
writeFileSync: fs.writeFileSync,
|
|
235
|
+
// On a successful upload, drop the "last session captured" breadcrumb
|
|
236
|
+
// that `codiedev doctor` reads to confirm the session landed.
|
|
237
|
+
recordSuccess: (job) => (0, utils_1.writeLastCapture)((0, utils_1.buildLastCaptureRecord)(job, Date.now())),
|
|
238
|
+
// Write structured outcome lines (ok / ERROR / gave-up) to the
|
|
239
|
+
// rotating capture log so users and doctor can see why captures fail.
|
|
240
|
+
log: (e) => (0, captureLog_1.appendCaptureLog)(e),
|
|
241
|
+
// Write per-session upload state so the throttle can defer later
|
|
242
|
+
// non-final uploads within the same window.
|
|
243
|
+
writeSessionState: utils_1.writeSessionUploadState,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
223
246
|
// Detached uploader child (spawned by the capture path below). The job —
|
|
224
247
|
// including transcript and token — is on disk; no stdin is needed, so handle
|
|
225
248
|
// this before readStdin() to avoid blocking on the ignored stdin pipe.
|
|
226
249
|
if (process.argv[2] === "upload") {
|
|
227
250
|
const jobFile = process.argv[3];
|
|
228
251
|
if (jobFile) {
|
|
229
|
-
await (0, utils_1.runCaptureUpload)(jobFile,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
252
|
+
await (0, utils_1.runCaptureUpload)(jobFile, makeUploadDeps());
|
|
253
|
+
}
|
|
254
|
+
process.exit(0);
|
|
255
|
+
}
|
|
256
|
+
// Deferred uploader child (spawned by runCaptureFinish when the per-turn
|
|
257
|
+
// Stop capture is inside the throttle window). Sleeps for the remaining
|
|
258
|
+
// delay, then claims the job file (if it hasn't been superseded by a newer
|
|
259
|
+
// turn) and uploads. No stdin needed — handle before readStdin().
|
|
260
|
+
if (process.argv[2] === "upload-deferred") {
|
|
261
|
+
const jobFile = process.argv[3];
|
|
262
|
+
const delayMs = Math.max(0, Number(process.argv[4]) || 0);
|
|
263
|
+
if (jobFile) {
|
|
264
|
+
await (0, utils_1.runDeferredUpload)(jobFile, delayMs, {
|
|
265
|
+
...makeUploadDeps(),
|
|
266
|
+
renameSync: fs.renameSync,
|
|
239
267
|
});
|
|
240
268
|
}
|
|
241
269
|
process.exit(0);
|
|
@@ -316,7 +344,40 @@ async function main() {
|
|
|
316
344
|
// Stop/sessionEnd backup path — separate from session capture and needs no
|
|
317
345
|
// transcript. Lists changed artifact files in the workspace and dispatches a
|
|
318
346
|
// detached child that backs them up privately. Best-effort, never blocks.
|
|
347
|
+
//
|
|
348
|
+
// Per-turn capture: Stop fires reliably in editor-embedded clients (VS Code
|
|
349
|
+
// extension) where SessionEnd may never come — the silent zero-capture bug.
|
|
350
|
+
// Same detached capture-finish child as SessionEnd, marked non-final so the
|
|
351
|
+
// server renews the session lease instead of summarizing immediately.
|
|
319
352
|
if (process.argv[2] === "backup") {
|
|
353
|
+
if (transcript_path) {
|
|
354
|
+
try {
|
|
355
|
+
(0, utils_1.dispatchCaptureFinish)({
|
|
356
|
+
// "capture" = the claude-code entrypoint literal, same as the
|
|
357
|
+
// SessionEnd path below (resolveMode maps it to claude-code).
|
|
358
|
+
mode: "capture",
|
|
359
|
+
session_id,
|
|
360
|
+
transcript_path,
|
|
361
|
+
cwd,
|
|
362
|
+
workspace_roots,
|
|
363
|
+
// non-final: server should renew the lease, not close it yet.
|
|
364
|
+
final: false,
|
|
365
|
+
}, process.argv[1], {
|
|
366
|
+
spawn: child_process_1.spawn,
|
|
367
|
+
writeFileSync: fs.writeFileSync,
|
|
368
|
+
openSync: fs.openSync,
|
|
369
|
+
closeSync: fs.closeSync,
|
|
370
|
+
// UNIQUE per dispatch (sid+pid+ts): Stop and SessionEnd children
|
|
371
|
+
// each own their input file exclusively — a shared per-session
|
|
372
|
+
// name let one child's `finally` unlink eat the other's input
|
|
373
|
+
// (lost final:true) or double-dispatch. See finishInputTmpFile.
|
|
374
|
+
tmpFile: (0, utils_1.finishInputTmpFile)(session_id),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// capture is best-effort; never break backup
|
|
379
|
+
}
|
|
380
|
+
}
|
|
320
381
|
const { runBackup } = await Promise.resolve().then(() => __importStar(require("./backup/run")));
|
|
321
382
|
runBackup(workspaceRoot, process.argv[1]);
|
|
322
383
|
process.exit(0);
|
|
@@ -338,14 +399,23 @@ async function main() {
|
|
|
338
399
|
process.exit(0);
|
|
339
400
|
}
|
|
340
401
|
try {
|
|
341
|
-
|
|
402
|
+
// "codiedev-finish-" prefix (NOT "codiedev-capture-") so the sweep regex
|
|
403
|
+
// /^codiedev-capture-.*\.json$/ never mistakes a finish-input file for an
|
|
404
|
+
// upload job. UNIQUE per dispatch (sid+pid+ts) so a concurrent Stop-hook
|
|
405
|
+
// child's `finally` unlink can never eat this input (lost final:true).
|
|
406
|
+
const jobFile = (0, utils_1.finishInputTmpFile)(session_id);
|
|
342
407
|
(0, utils_1.dispatchCaptureFinish)({
|
|
343
408
|
mode: process.argv[2] ?? "capture",
|
|
344
409
|
session_id,
|
|
345
410
|
transcript_path,
|
|
346
411
|
cwd,
|
|
347
412
|
workspace_roots,
|
|
348
|
-
|
|
413
|
+
// final:true ONLY for claude-code's SessionEnd. Codex routes its
|
|
414
|
+
// per-turn Stop hook through "capture-codex" — final:true there would
|
|
415
|
+
// close the server lease every turn. Codex/Cursor keep legacy
|
|
416
|
+
// behaviour (final omitted).
|
|
417
|
+
final: (0, utils_1.resolveCaptureFinal)(process.argv[2]),
|
|
418
|
+
}, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile, openSync: fs.openSync, closeSync: fs.closeSync });
|
|
349
419
|
}
|
|
350
420
|
catch (err) {
|
|
351
421
|
process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
|
|
@@ -366,6 +436,22 @@ async function runCaptureFinish(input) {
|
|
|
366
436
|
const { session_id, transcript_path, cwd, workspace_roots } = input;
|
|
367
437
|
if (!transcript_path)
|
|
368
438
|
return;
|
|
439
|
+
// Log that this child has started processing — the first diagnostic proof
|
|
440
|
+
// that the detached capture-finish child actually ran (visible in doctor).
|
|
441
|
+
(0, captureLog_1.appendCaptureLog)({ stage: "capture-finish", sessionId: session_id, outcome: "start" });
|
|
442
|
+
try {
|
|
443
|
+
await runCaptureFinishInner(input, config, session_id, transcript_path, cwd, workspace_roots);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
(0, captureLog_1.appendCaptureLog)({
|
|
447
|
+
stage: "capture-finish",
|
|
448
|
+
sessionId: session_id,
|
|
449
|
+
outcome: "ERROR",
|
|
450
|
+
detail: (err instanceof Error ? err.message : String(err)).slice(0, 200),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function runCaptureFinishInner(input, config, session_id, transcript_path, cwd, workspace_roots) {
|
|
369
455
|
const mode = resolveMode(input.mode);
|
|
370
456
|
const workspaceRoot = workspace_roots && workspace_roots.length > 0
|
|
371
457
|
? workspace_roots[0]
|
|
@@ -458,23 +544,59 @@ async function runCaptureFinish(input) {
|
|
|
458
544
|
sessionStartedAt,
|
|
459
545
|
interruptCount: steering.interrupts,
|
|
460
546
|
rejectionCount: steering.rejections,
|
|
547
|
+
// Carry the final flag into the HTTP body — the server uses it to decide
|
|
548
|
+
// whether to close the session lease (true) or renew it (false/absent).
|
|
549
|
+
final: input.final,
|
|
550
|
+
});
|
|
551
|
+
// Throttle decision: final captures go immediately; non-final (per-turn Stop)
|
|
552
|
+
// are deferred if we uploaded this session recently, to avoid per-turn churn.
|
|
553
|
+
const uploadState = (0, utils_1.readSessionUploadState)(session_id ?? "");
|
|
554
|
+
const decision = (0, utils_1.decideDispatch)({
|
|
555
|
+
final: input.final === true,
|
|
556
|
+
lastUploadAt: uploadState?.lastUploadAt ?? null,
|
|
557
|
+
now: Date.now(),
|
|
558
|
+
});
|
|
559
|
+
// Log capture-finish outcome with dispatch context so doctor can see whether
|
|
560
|
+
// the upload went immediately or was deferred (and by how much).
|
|
561
|
+
(0, captureLog_1.appendCaptureLog)({
|
|
562
|
+
stage: "capture-finish",
|
|
563
|
+
sessionId: session_id,
|
|
564
|
+
outcome: decision.mode === "deferred" ? "deferred" : "ok",
|
|
565
|
+
detail: decision.mode === "deferred"
|
|
566
|
+
? `dispatch=deferred delayMs=${decision.delayMs} final=${input.final === true} repo=${remoteUrl ?? "-"} branch=${branch ?? "-"} msgs=${stats.messageCount}`
|
|
567
|
+
: `dispatch=immediate final=${input.final === true} repo=${remoteUrl ?? "-"} branch=${branch ?? "-"} msgs=${stats.messageCount}`,
|
|
461
568
|
});
|
|
462
569
|
// Fire the upload from a further-detached child. We're already off the
|
|
463
570
|
// shutdown clock here, but reusing dispatchCaptureDetached keeps the upload
|
|
464
571
|
// retry + last-capture breadcrumb logic (runCaptureUpload) intact verbatim.
|
|
572
|
+
// Per-session job file: one file per session; newer turns overwrite it,
|
|
573
|
+
// which is the "supersede" mechanism for the deferred path.
|
|
465
574
|
try {
|
|
466
575
|
const hookScriptPath = process.argv[1];
|
|
467
576
|
const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
577
|
+
if (decision.mode === "immediate") {
|
|
578
|
+
(0, utils_1.dispatchCaptureDetached)({ url: `${config.backendUrl}/api/captureSession`, token: config.token, body: payload }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
|
|
579
|
+
// Retry any earlier capture jobs that failed to upload (transient network
|
|
580
|
+
// blips), excluding the one we just dispatched. Best-effort, detached.
|
|
581
|
+
// Skip the sweep on deferred path — the deferred child will handle uploading.
|
|
582
|
+
(0, utils_1.sweepPendingCaptures)({
|
|
583
|
+
dir: os.tmpdir(),
|
|
584
|
+
readdirSync: fs.readdirSync,
|
|
585
|
+
renameSync: fs.renameSync,
|
|
586
|
+
statSync: fs.statSync,
|
|
587
|
+
spawn: child_process_1.spawn,
|
|
588
|
+
hookScriptPath,
|
|
589
|
+
exclude: tmpFile,
|
|
590
|
+
openSync: fs.openSync,
|
|
591
|
+
closeSync: fs.closeSync,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
// Deferred: write the job file (newer turns overwrite = supersede), then
|
|
596
|
+
// spawn the deferred-upload child which will sleep, claim, and upload.
|
|
597
|
+
// Same stdio/fd-hygiene contract as dispatchCaptureDetached.
|
|
598
|
+
(0, utils_1.dispatchDeferredUpload)({ url: `${config.backendUrl}/api/captureSession`, token: config.token, body: payload }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync }, decision.delayMs);
|
|
599
|
+
}
|
|
478
600
|
}
|
|
479
601
|
catch (err) {
|
|
480
602
|
process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import { spawn as nodeSpawn } from "child_process";
|
|
3
|
+
import type { CaptureLogEntry } from "./captureLog";
|
|
3
4
|
export interface CodiedevConfig {
|
|
4
5
|
token: string;
|
|
5
6
|
companyId: string;
|
|
@@ -62,6 +63,7 @@ export interface CapturePayloadInput {
|
|
|
62
63
|
sessionStartedAt?: number | null;
|
|
63
64
|
interruptCount?: number;
|
|
64
65
|
rejectionCount?: number;
|
|
66
|
+
final?: boolean;
|
|
65
67
|
}
|
|
66
68
|
export declare function buildCapturePayload(input: CapturePayloadInput): Record<string, unknown>;
|
|
67
69
|
export declare function hashToken(token: string): string;
|
|
@@ -174,6 +176,8 @@ interface DispatchDeps {
|
|
|
174
176
|
writeFileSync: typeof fs.writeFileSync;
|
|
175
177
|
tmpFile: string;
|
|
176
178
|
nodePath?: string;
|
|
179
|
+
openSync?: typeof fs.openSync;
|
|
180
|
+
closeSync?: typeof fs.closeSync;
|
|
177
181
|
}
|
|
178
182
|
export interface CaptureFinishInput {
|
|
179
183
|
mode: string;
|
|
@@ -181,19 +185,65 @@ export interface CaptureFinishInput {
|
|
|
181
185
|
transcript_path?: string;
|
|
182
186
|
cwd?: string;
|
|
183
187
|
workspace_roots?: string[];
|
|
188
|
+
final?: boolean;
|
|
184
189
|
}
|
|
185
190
|
export declare function dispatchCaptureFinish(input: CaptureFinishInput, hookScriptPath: string, deps: DispatchDeps): void;
|
|
186
191
|
export declare function dispatchCaptureDetached(job: CaptureUploadJob, hookScriptPath: string, deps: DispatchDeps): void;
|
|
192
|
+
export declare function dispatchDeferredUpload(job: CaptureUploadJob, hookScriptPath: string, deps: DispatchDeps, delayMs: number): void;
|
|
187
193
|
interface SweepDeps {
|
|
188
194
|
dir: string;
|
|
189
195
|
readdirSync: (dir: string) => string[];
|
|
196
|
+
renameSync: (from: string, to: string) => void;
|
|
197
|
+
statSync: (p: string) => {
|
|
198
|
+
mtimeMs: number;
|
|
199
|
+
};
|
|
190
200
|
spawn: typeof nodeSpawn;
|
|
191
201
|
hookScriptPath: string;
|
|
192
202
|
nodePath?: string;
|
|
193
203
|
exclude?: string;
|
|
194
204
|
max?: number;
|
|
205
|
+
now?: () => number;
|
|
206
|
+
openSync?: typeof fs.openSync;
|
|
207
|
+
closeSync?: typeof fs.closeSync;
|
|
208
|
+
pruneState?: () => void;
|
|
195
209
|
}
|
|
210
|
+
export declare const CLAIM_STALE_MS: number;
|
|
196
211
|
export declare function sweepPendingCaptures(deps: SweepDeps): void;
|
|
212
|
+
export declare const CAPTURE_THROTTLE_MS: number;
|
|
213
|
+
export declare const SWEEP_MIN_AGE_MS: number;
|
|
214
|
+
export declare function resolveCaptureFinal(argv2: string | undefined): boolean | undefined;
|
|
215
|
+
export declare function finishInputTmpFile(sessionId: string | undefined): string;
|
|
216
|
+
export declare function decideDispatch(args: {
|
|
217
|
+
final: boolean;
|
|
218
|
+
lastUploadAt: number | null;
|
|
219
|
+
now: number;
|
|
220
|
+
}): {
|
|
221
|
+
mode: "immediate";
|
|
222
|
+
} | {
|
|
223
|
+
mode: "deferred";
|
|
224
|
+
delayMs: number;
|
|
225
|
+
};
|
|
226
|
+
export interface SessionUploadState {
|
|
227
|
+
lastUploadAt: number;
|
|
228
|
+
}
|
|
229
|
+
interface SessionStateDeps {
|
|
230
|
+
readFileSync: typeof fs.readFileSync;
|
|
231
|
+
existsSync: typeof fs.existsSync;
|
|
232
|
+
}
|
|
233
|
+
interface SessionStateWriteDeps {
|
|
234
|
+
writeFileSync: typeof fs.writeFileSync;
|
|
235
|
+
existsSync: typeof fs.existsSync;
|
|
236
|
+
mkdirSync: typeof fs.mkdirSync;
|
|
237
|
+
}
|
|
238
|
+
export declare function readSessionUploadState(sessionId: string, deps?: SessionStateDeps): SessionUploadState | null;
|
|
239
|
+
export declare function writeSessionUploadState(sessionId: string, state: SessionUploadState, deps?: SessionStateWriteDeps): void;
|
|
240
|
+
export declare function pruneCaptureStateDir(deps?: {
|
|
241
|
+
readdirSync: (d: string) => string[];
|
|
242
|
+
statSync: (p: string) => {
|
|
243
|
+
mtimeMs: number;
|
|
244
|
+
};
|
|
245
|
+
unlinkSync: (p: string) => void;
|
|
246
|
+
}, dir?: string, now?: number): void;
|
|
197
247
|
interface UploadDeps {
|
|
198
248
|
post: (url: string, body: Record<string, unknown>, token: string) => Promise<void>;
|
|
199
249
|
readFileSync: typeof fs.readFileSync;
|
|
@@ -201,6 +251,8 @@ interface UploadDeps {
|
|
|
201
251
|
writeFileSync?: typeof fs.writeFileSync;
|
|
202
252
|
now?: () => number;
|
|
203
253
|
recordSuccess?: (job: CaptureUploadJob) => void;
|
|
254
|
+
log?: (entry: CaptureLogEntry) => void;
|
|
255
|
+
writeSessionState?: (sessionId: string, state: SessionUploadState) => void;
|
|
204
256
|
}
|
|
205
257
|
export interface LastCaptureRecord {
|
|
206
258
|
sessionId: string | undefined;
|
|
@@ -225,4 +277,9 @@ interface LastCaptureReadDeps {
|
|
|
225
277
|
}
|
|
226
278
|
export declare function readLastCapture(deps?: LastCaptureReadDeps, filePath?: string): LastCaptureRecord | null;
|
|
227
279
|
export declare function runCaptureUpload(jobFile: string, deps: UploadDeps): Promise<void>;
|
|
280
|
+
interface DeferredUploadDeps extends UploadDeps {
|
|
281
|
+
renameSync: typeof fs.renameSync;
|
|
282
|
+
sleep?: (ms: number) => Promise<void>;
|
|
283
|
+
}
|
|
284
|
+
export declare function runDeferredUpload(jobFile: string, delayMs: number, deps: DeferredUploadDeps): Promise<void>;
|
|
228
285
|
export {};
|
package/dist/utils.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
|
|
36
|
+
exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
|
|
37
37
|
exports.readConfig = readConfig;
|
|
38
38
|
exports.writeConfig = writeConfig;
|
|
39
39
|
exports.parseGitRemoteFromConfig = parseGitRemoteFromConfig;
|
|
@@ -85,16 +85,25 @@ exports.parseCodexStats = parseCodexStats;
|
|
|
85
85
|
exports.extractCodexRepoUrl = extractCodexRepoUrl;
|
|
86
86
|
exports.dispatchCaptureFinish = dispatchCaptureFinish;
|
|
87
87
|
exports.dispatchCaptureDetached = dispatchCaptureDetached;
|
|
88
|
+
exports.dispatchDeferredUpload = dispatchDeferredUpload;
|
|
88
89
|
exports.sweepPendingCaptures = sweepPendingCaptures;
|
|
90
|
+
exports.resolveCaptureFinal = resolveCaptureFinal;
|
|
91
|
+
exports.finishInputTmpFile = finishInputTmpFile;
|
|
92
|
+
exports.decideDispatch = decideDispatch;
|
|
93
|
+
exports.readSessionUploadState = readSessionUploadState;
|
|
94
|
+
exports.writeSessionUploadState = writeSessionUploadState;
|
|
95
|
+
exports.pruneCaptureStateDir = pruneCaptureStateDir;
|
|
89
96
|
exports.buildLastCaptureRecord = buildLastCaptureRecord;
|
|
90
97
|
exports.writeLastCapture = writeLastCapture;
|
|
91
98
|
exports.readLastCapture = readLastCapture;
|
|
92
99
|
exports.runCaptureUpload = runCaptureUpload;
|
|
100
|
+
exports.runDeferredUpload = runDeferredUpload;
|
|
93
101
|
const fs = __importStar(require("fs"));
|
|
94
102
|
const path = __importStar(require("path"));
|
|
95
103
|
const os = __importStar(require("os"));
|
|
96
104
|
const child_process_1 = require("child_process");
|
|
97
105
|
const detection_1 = require("./detection");
|
|
106
|
+
const captureLog_1 = require("./captureLog");
|
|
98
107
|
const CONFIG_DIR = path.join(os.homedir(), ".codiedev");
|
|
99
108
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
100
109
|
function readConfig() {
|
|
@@ -676,6 +685,8 @@ function buildCapturePayload(input) {
|
|
|
676
685
|
sessionStartedAt: input.sessionStartedAt ?? undefined,
|
|
677
686
|
interruptCount: input.interruptCount,
|
|
678
687
|
rejectionCount: input.rejectionCount,
|
|
688
|
+
// final is only emitted when explicitly set (not for Codex/Cursor).
|
|
689
|
+
final: input.final,
|
|
679
690
|
};
|
|
680
691
|
}
|
|
681
692
|
function hashToken(token) {
|
|
@@ -1626,6 +1637,32 @@ function extractCodexRepoUrl(content) {
|
|
|
1626
1637
|
// failing job never litters /tmp forever. Paired with UPLOAD_TTL_MS.
|
|
1627
1638
|
exports.MAX_UPLOAD_ATTEMPTS = 5;
|
|
1628
1639
|
exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1640
|
+
// Open the capture log for appending so a detached child's stderr lands there
|
|
1641
|
+
// instead of vanishing into stdio:"ignore". Returns the stdio value to spawn
|
|
1642
|
+
// with plus the parent's fd (to close after spawn — the child gets its own
|
|
1643
|
+
// duplicate). Falls back to plain "ignore" when openSync is absent or throws,
|
|
1644
|
+
// so a missing/unwritable log dir never breaks a dispatch.
|
|
1645
|
+
function openCaptureLogStdio(openSync) {
|
|
1646
|
+
if (!openSync)
|
|
1647
|
+
return { stdio: "ignore" };
|
|
1648
|
+
try {
|
|
1649
|
+
const fd = openSync(captureLog_1.CAPTURE_LOG_PATH, "a");
|
|
1650
|
+
return { stdio: ["ignore", "ignore", fd], fd };
|
|
1651
|
+
}
|
|
1652
|
+
catch {
|
|
1653
|
+
return { stdio: "ignore" };
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
// Best-effort close of the parent's log-fd copy. No-op when there's no fd or
|
|
1657
|
+
// no closeSync dep — never throws into the capture path.
|
|
1658
|
+
function closeCaptureLogFd(fd, closeSync) {
|
|
1659
|
+
if (fd === undefined || !closeSync)
|
|
1660
|
+
return;
|
|
1661
|
+
try {
|
|
1662
|
+
closeSync(fd);
|
|
1663
|
+
}
|
|
1664
|
+
catch { /* best-effort */ }
|
|
1665
|
+
}
|
|
1629
1666
|
// Hand the SessionEnd capture off to a detached child so the inline hook can
|
|
1630
1667
|
// exit 0 immediately. Detaching only the network upload (dispatchCaptureDetached)
|
|
1631
1668
|
// wasn't enough: the synchronous transcript read + parse and — the killer on
|
|
@@ -1637,8 +1674,21 @@ exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
1637
1674
|
// small file write plus a spawn.
|
|
1638
1675
|
function dispatchCaptureFinish(input, hookScriptPath, deps) {
|
|
1639
1676
|
deps.writeFileSync(deps.tmpFile, JSON.stringify(input), { mode: 0o600 });
|
|
1640
|
-
|
|
1641
|
-
|
|
1677
|
+
// Route the child's stderr to the capture log so any crash output is
|
|
1678
|
+
// visible to `codiedev doctor`. The caller must explicitly pass openSync
|
|
1679
|
+
// (e.g. fs.openSync in hook.ts) to enable this; when omitted (tests,
|
|
1680
|
+
// older call-sites) we fall back to "ignore" so existing behaviour is
|
|
1681
|
+
// unchanged and no real fd is opened.
|
|
1682
|
+
const { stdio, fd } = openCaptureLogStdio(deps.openSync);
|
|
1683
|
+
try {
|
|
1684
|
+
const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "capture-finish", deps.tmpFile], { detached: true, stdio });
|
|
1685
|
+
child.unref();
|
|
1686
|
+
}
|
|
1687
|
+
finally {
|
|
1688
|
+
// Close the PARENT's fd copy even when spawn throws — the child (if
|
|
1689
|
+
// spawned) holds its own duplicate, so this never cuts off its stderr.
|
|
1690
|
+
closeCaptureLogFd(fd, deps.closeSync);
|
|
1691
|
+
}
|
|
1642
1692
|
}
|
|
1643
1693
|
// Fire-and-forget the session-capture upload from a fully detached child so the
|
|
1644
1694
|
// SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
|
|
@@ -1647,12 +1697,52 @@ function dispatchCaptureFinish(input, hookScriptPath, deps) {
|
|
|
1647
1697
|
// passed via argv so it never shows up in the process list.
|
|
1648
1698
|
function dispatchCaptureDetached(job, hookScriptPath, deps) {
|
|
1649
1699
|
deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
|
|
1650
|
-
|
|
1651
|
-
|
|
1700
|
+
// Route the child's stderr to the capture log (same pattern as
|
|
1701
|
+
// dispatchCaptureFinish). The caller must explicitly pass openSync to
|
|
1702
|
+
// enable this; when omitted we fall back to "ignore" so existing callers
|
|
1703
|
+
// keep working unchanged.
|
|
1704
|
+
const { stdio, fd } = openCaptureLogStdio(deps.openSync);
|
|
1705
|
+
try {
|
|
1706
|
+
const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload", deps.tmpFile], { detached: true, stdio });
|
|
1707
|
+
child.unref();
|
|
1708
|
+
}
|
|
1709
|
+
finally {
|
|
1710
|
+
// Close the PARENT's fd copy even when spawn throws — the child (if
|
|
1711
|
+
// spawned) holds its own duplicate, so this never cuts off its stderr.
|
|
1712
|
+
closeCaptureLogFd(fd, deps.closeSync);
|
|
1713
|
+
}
|
|
1652
1714
|
}
|
|
1715
|
+
// Deferred twin of dispatchCaptureDetached: persist the job, then spawn the
|
|
1716
|
+
// `upload-deferred` child which sleeps for delayMs before claiming + uploading
|
|
1717
|
+
// (or finding itself superseded by a newer turn). Used when decideDispatch
|
|
1718
|
+
// says a non-final per-turn capture is inside the throttle window. Identical
|
|
1719
|
+
// stdio/fd-hygiene contract to the other dispatch fns.
|
|
1720
|
+
function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
|
|
1721
|
+
deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
|
|
1722
|
+
const { stdio, fd } = openCaptureLogStdio(deps.openSync);
|
|
1723
|
+
try {
|
|
1724
|
+
const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload-deferred", deps.tmpFile, String(delayMs)], { detached: true, stdio });
|
|
1725
|
+
child.unref();
|
|
1726
|
+
}
|
|
1727
|
+
finally {
|
|
1728
|
+
// Close the PARENT's fd copy even when spawn throws — the child (if
|
|
1729
|
+
// spawned) holds its own duplicate, so this never cuts off its stderr.
|
|
1730
|
+
closeCaptureLogFd(fd, deps.closeSync);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
// A claimed job whose uploader hasn't finished after this long is presumed
|
|
1734
|
+
// crashed and gets renamed back to its canonical name for a later retry.
|
|
1735
|
+
exports.CLAIM_STALE_MS = 15 * 60 * 1000;
|
|
1653
1736
|
// Retry kept-on-failure capture jobs: re-dispatch each pending
|
|
1654
1737
|
// `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
|
|
1655
1738
|
// gives up at the cap). Best-effort — never throws into the hook.
|
|
1739
|
+
//
|
|
1740
|
+
// Each job is CLAIMED with an atomic rename before dispatch. Several
|
|
1741
|
+
// session-end hooks can run this sweep over the same tmpdir at once, and
|
|
1742
|
+
// without the claim each of them dispatched an uploader for the same job —
|
|
1743
|
+
// every duplicate upload triggered a full server-side re-summarize of the
|
|
1744
|
+
// session (real LLM spend per duplicate). Rename is atomic, so exactly one
|
|
1745
|
+
// sweeper wins each file; the losers' renames throw and they skip it.
|
|
1656
1746
|
function sweepPendingCaptures(deps) {
|
|
1657
1747
|
let files;
|
|
1658
1748
|
try {
|
|
@@ -1661,20 +1751,190 @@ function sweepPendingCaptures(deps) {
|
|
|
1661
1751
|
catch {
|
|
1662
1752
|
return;
|
|
1663
1753
|
}
|
|
1754
|
+
const now = deps.now?.() ?? Date.now();
|
|
1755
|
+
// Recover claims orphaned by a crashed/killed uploader: a stale claim is
|
|
1756
|
+
// renamed back to its canonical name so a LATER sweep retries it. (Not this
|
|
1757
|
+
// one — the uploader may legitimately still be running near the threshold,
|
|
1758
|
+
// and dispatching reclaimed work immediately would race it.)
|
|
1759
|
+
for (const f of files) {
|
|
1760
|
+
const m = /^(codiedev-capture-.*\.json)\.claim-\d+$/.exec(f);
|
|
1761
|
+
if (!m)
|
|
1762
|
+
continue;
|
|
1763
|
+
const claimedPath = path.join(deps.dir, f);
|
|
1764
|
+
try {
|
|
1765
|
+
if (now - deps.statSync(claimedPath).mtimeMs > exports.CLAIM_STALE_MS) {
|
|
1766
|
+
deps.renameSync(claimedPath, path.join(deps.dir, m[1]));
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
catch {
|
|
1770
|
+
// raced another sweeper or the uploader just finished — fine
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1664
1773
|
const jobs = files
|
|
1665
1774
|
.filter((f) => /^codiedev-capture-.*\.json$/.test(f))
|
|
1666
1775
|
.map((f) => path.join(deps.dir, f))
|
|
1667
1776
|
.filter((p) => p !== deps.exclude)
|
|
1777
|
+
// Min-age guard: a YOUNG job file may be a sleeping deferred upload whose
|
|
1778
|
+
// own upload-deferred child will consume it within CAPTURE_THROTTLE_MS of
|
|
1779
|
+
// its mtime. The sweep must not steal it — that would upload per-turn and
|
|
1780
|
+
// defeat the throttle with 2+ concurrent sessions. Only genuinely stuck
|
|
1781
|
+
// jobs (older than SWEEP_MIN_AGE_MS) are retried. A stat failure means the
|
|
1782
|
+
// job was claimed/consumed mid-sweep — skip it.
|
|
1783
|
+
.filter((p) => {
|
|
1784
|
+
try {
|
|
1785
|
+
return now - deps.statSync(p).mtimeMs >= exports.SWEEP_MIN_AGE_MS;
|
|
1786
|
+
}
|
|
1787
|
+
catch {
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
})
|
|
1668
1791
|
.slice(0, deps.max ?? 10);
|
|
1792
|
+
// One log fd shared by every re-dispatched uploader in this batch (each
|
|
1793
|
+
// spawn duplicates it); the parent's copy is closed after the loop.
|
|
1794
|
+
const { stdio, fd } = openCaptureLogStdio(deps.openSync);
|
|
1669
1795
|
for (const jobFile of jobs) {
|
|
1796
|
+
const claimed = `${jobFile}.claim-${process.pid}`;
|
|
1670
1797
|
try {
|
|
1671
|
-
|
|
1798
|
+
deps.renameSync(jobFile, claimed);
|
|
1799
|
+
}
|
|
1800
|
+
catch {
|
|
1801
|
+
continue; // another sweeper claimed it first
|
|
1802
|
+
}
|
|
1803
|
+
try {
|
|
1804
|
+
const child = deps.spawn(deps.nodePath ?? process.execPath, [deps.hookScriptPath, "upload", claimed], { detached: true, stdio });
|
|
1672
1805
|
child.unref();
|
|
1673
1806
|
}
|
|
1674
1807
|
catch {
|
|
1675
|
-
//
|
|
1808
|
+
// Couldn't fork the uploader — release the claim so a later sweep
|
|
1809
|
+
// retries instead of the job dying invisible under the claimed name.
|
|
1810
|
+
try {
|
|
1811
|
+
deps.renameSync(claimed, jobFile);
|
|
1812
|
+
}
|
|
1813
|
+
catch {
|
|
1814
|
+
// best-effort
|
|
1815
|
+
}
|
|
1676
1816
|
}
|
|
1677
1817
|
}
|
|
1818
|
+
closeCaptureLogFd(fd, deps.closeSync);
|
|
1819
|
+
// Housekeeping: prune per-session upload-state files older than the upload
|
|
1820
|
+
// TTL so capture-state doesn't grow forever. Best-effort, never throws.
|
|
1821
|
+
try {
|
|
1822
|
+
(deps.pruneState ?? pruneCaptureStateDir)();
|
|
1823
|
+
}
|
|
1824
|
+
catch {
|
|
1825
|
+
// best-effort
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1829
|
+
// Per-session upload throttle — defers mid-session Stop-hook uploads so we
|
|
1830
|
+
// don't hammer the server (and re-summarize) after every single turn.
|
|
1831
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1832
|
+
// Minimum interval between non-final uploads for the same session. 10 minutes:
|
|
1833
|
+
// fine-grained enough to capture recent work while avoiding per-turn churn.
|
|
1834
|
+
exports.CAPTURE_THROTTLE_MS = 10 * 60 * 1000;
|
|
1835
|
+
// Minimum AGE of a job file before the retry sweep may claim it. An actively
|
|
1836
|
+
// deferred job is consumed by its own upload-deferred child within
|
|
1837
|
+
// CAPTURE_THROTTLE_MS of its mtime — a sweep stealing it early would upload
|
|
1838
|
+
// per-turn and defeat the throttle whenever 2+ sessions run concurrently.
|
|
1839
|
+
// The 2-min slack covers the child's wake-up + claim. Side effect: failed
|
|
1840
|
+
// uploads (whose retry bump refreshes mtime) now wait ~12 min between retry
|
|
1841
|
+
// attempts — fine for a 7-day-TTL retry queue, and a free rate-limit on
|
|
1842
|
+
// retry storms.
|
|
1843
|
+
exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS + 2 * 60 * 1000;
|
|
1844
|
+
// Map the hook's capture argv to the payload's `final` flag. EXPLICIT
|
|
1845
|
+
// ALLOWLIST: only claude-code's SessionEnd entrypoints ("capture", or the bare
|
|
1846
|
+
// hook with no subcommand) are a true final signal. Everything else —
|
|
1847
|
+
// capture-codex (Codex's Stop fires per-turn; final:true would close the
|
|
1848
|
+
// server-side session lease every turn), capture-cursor, and any FUTURE mode
|
|
1849
|
+
// (e.g. a capture-windsurf added to resolveMode but not audited here) — gets
|
|
1850
|
+
// undefined, i.e. legacy server behaviour. Defaulting unknowns to true would
|
|
1851
|
+
// silently re-introduce the per-turn lease-close bug for every new agent.
|
|
1852
|
+
function resolveCaptureFinal(argv2) {
|
|
1853
|
+
return argv2 === "capture" || argv2 === undefined ? true : undefined;
|
|
1854
|
+
}
|
|
1855
|
+
// Build a UNIQUE finish-input tmpFile name per dispatch. Both the Stop hook and
|
|
1856
|
+
// SessionEnd dispatch capture-finish children, and the child unlinks its input
|
|
1857
|
+
// in `finally` — a SHARED per-session name let interleavings drop the final
|
|
1858
|
+
// dispatch (Stop's child unlinks the file SessionEnd just wrote) or
|
|
1859
|
+
// double-dispatch. Unique names mean each child owns its file exclusively; at
|
|
1860
|
+
// worst concurrent Stop+SessionEnd children both upload, which is harmless
|
|
1861
|
+
// (the server upserts by sessionId). Orphans (child dies before unlink) sit in
|
|
1862
|
+
// tmpdir OUTSIDE the sweep regex (codiedev-finish- ≠ codiedev-capture-);
|
|
1863
|
+
// negligible volume (one tiny JSON per crashed child), though note Windows
|
|
1864
|
+
// %TEMP% is not auto-cleaned — acceptable.
|
|
1865
|
+
function finishInputTmpFile(sessionId) {
|
|
1866
|
+
return path.join(os.tmpdir(), `codiedev-finish-${sessionId ?? process.pid}-${process.pid}-${Date.now()}.json`);
|
|
1867
|
+
}
|
|
1868
|
+
// Decide whether to upload immediately or defer until the throttle window expires.
|
|
1869
|
+
// final=true → always immediate (SessionEnd: close the session now)
|
|
1870
|
+
// null → immediate (first upload for this session)
|
|
1871
|
+
// outside window → immediate
|
|
1872
|
+
// inside window → deferred with exact remaining delay
|
|
1873
|
+
// future clock skew → deferred clamped to CAPTURE_THROTTLE_MS
|
|
1874
|
+
function decideDispatch(args) {
|
|
1875
|
+
if (args.final || args.lastUploadAt === null)
|
|
1876
|
+
return { mode: "immediate" };
|
|
1877
|
+
const elapsed = args.now - args.lastUploadAt;
|
|
1878
|
+
// Clock-skew guard: elapsed < 0 means lastUploadAt is in the future. Clamping
|
|
1879
|
+
// to 0 also bounds delayMs to at most CAPTURE_THROTTLE_MS (safeElapsed >= 0).
|
|
1880
|
+
const safeElapsed = Math.max(0, elapsed);
|
|
1881
|
+
if (safeElapsed >= exports.CAPTURE_THROTTLE_MS)
|
|
1882
|
+
return { mode: "immediate" };
|
|
1883
|
+
return { mode: "deferred", delayMs: exports.CAPTURE_THROTTLE_MS - safeElapsed };
|
|
1884
|
+
}
|
|
1885
|
+
// Per-session state dir: ~/.codiedev/capture-state/<sanitized-session-id>.json
|
|
1886
|
+
const CAPTURE_STATE_DIR = path.join(CONFIG_DIR, "capture-state");
|
|
1887
|
+
function captureStatePath(sessionId) {
|
|
1888
|
+
const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
1889
|
+
return path.join(CAPTURE_STATE_DIR, `${safe}.json`);
|
|
1890
|
+
}
|
|
1891
|
+
// Read the per-session upload state from ~/.codiedev/capture-state/<id>.json.
|
|
1892
|
+
// Returns null when missing or unreadable. Never throws.
|
|
1893
|
+
function readSessionUploadState(sessionId, deps = fs) {
|
|
1894
|
+
try {
|
|
1895
|
+
const p = captureStatePath(sessionId);
|
|
1896
|
+
if (!deps.existsSync(p))
|
|
1897
|
+
return null;
|
|
1898
|
+
return JSON.parse(deps.readFileSync(p, "utf8"));
|
|
1899
|
+
}
|
|
1900
|
+
catch {
|
|
1901
|
+
return null;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
// Write the per-session upload state to ~/.codiedev/capture-state/<id>.json.
|
|
1905
|
+
// Best-effort: never throws into the capture path.
|
|
1906
|
+
function writeSessionUploadState(sessionId, state, deps = fs) {
|
|
1907
|
+
try {
|
|
1908
|
+
if (!deps.existsSync(CAPTURE_STATE_DIR)) {
|
|
1909
|
+
deps.mkdirSync(CAPTURE_STATE_DIR, { recursive: true });
|
|
1910
|
+
}
|
|
1911
|
+
const p = captureStatePath(sessionId);
|
|
1912
|
+
deps.writeFileSync(p, JSON.stringify(state), { mode: 0o600 });
|
|
1913
|
+
}
|
|
1914
|
+
catch {
|
|
1915
|
+
// best-effort — never break the capture path
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
// Prune per-session upload-state files older than UPLOAD_TTL_MS (7 days — same
|
|
1919
|
+
// TTL as the upload retry queue) so ~/.codiedev/capture-state (one tiny file
|
|
1920
|
+
// per session) doesn't grow forever. Called from sweepPendingCaptures, i.e. on
|
|
1921
|
+
// the cadence captures already run housekeeping. Best-effort: never throws.
|
|
1922
|
+
function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now()) {
|
|
1923
|
+
try {
|
|
1924
|
+
for (const f of deps.readdirSync(dir)) {
|
|
1925
|
+
const p = path.join(dir, f);
|
|
1926
|
+
try {
|
|
1927
|
+
if (now - deps.statSync(p).mtimeMs > exports.UPLOAD_TTL_MS)
|
|
1928
|
+
deps.unlinkSync(p);
|
|
1929
|
+
}
|
|
1930
|
+
catch {
|
|
1931
|
+
// raced another pruner or unreadable — skip
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
catch {
|
|
1936
|
+
// dir missing/unreadable — nothing to prune
|
|
1937
|
+
}
|
|
1678
1938
|
}
|
|
1679
1939
|
// "Last session captured" breadcrumb. Written next to config.json on a
|
|
1680
1940
|
// successful capture upload; read by `codiedev doctor` so a user can confirm
|
|
@@ -1732,6 +1992,7 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
1732
1992
|
catch { /* gone */ }
|
|
1733
1993
|
return;
|
|
1734
1994
|
}
|
|
1995
|
+
const sessionId = job.body?.sessionId;
|
|
1735
1996
|
try {
|
|
1736
1997
|
await deps.post(job.url, job.body, job.token);
|
|
1737
1998
|
// success — drop a "last session captured" breadcrumb (before removing the
|
|
@@ -1742,28 +2003,82 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
1742
2003
|
}
|
|
1743
2004
|
catch { /* breadcrumb is best-effort */ }
|
|
1744
2005
|
}
|
|
2006
|
+
// Write the per-session upload state so the throttle can defer the next
|
|
2007
|
+
// non-final (per-turn) upload until the window expires.
|
|
2008
|
+
const uploadedSessionId = job.body?.sessionId;
|
|
2009
|
+
if (uploadedSessionId && deps.writeSessionState) {
|
|
2010
|
+
try {
|
|
2011
|
+
deps.writeSessionState(uploadedSessionId, { lastUploadAt: deps.now ? deps.now() : Date.now() });
|
|
2012
|
+
}
|
|
2013
|
+
catch { /* best-effort */ }
|
|
2014
|
+
}
|
|
2015
|
+
const attempt = (job.attempts ?? 0) + 1;
|
|
2016
|
+
if (deps.log) {
|
|
2017
|
+
try {
|
|
2018
|
+
deps.log({ stage: "upload", sessionId, outcome: "ok", detail: `attempt=${attempt}` });
|
|
2019
|
+
}
|
|
2020
|
+
catch { /* log is best-effort */ }
|
|
2021
|
+
}
|
|
1745
2022
|
try {
|
|
1746
2023
|
deps.unlinkSync(jobFile);
|
|
1747
2024
|
}
|
|
1748
2025
|
catch { /* gone */ }
|
|
1749
2026
|
return;
|
|
1750
2027
|
}
|
|
1751
|
-
catch {
|
|
2028
|
+
catch (err) {
|
|
1752
2029
|
// Failure: keep the job for a later sweep to retry, UNLESS we've hit the
|
|
1753
2030
|
// attempt cap or TTL — then give up so /tmp doesn't accumulate forever.
|
|
1754
2031
|
const now = deps.now ? deps.now() : Date.now();
|
|
1755
2032
|
const attempts = (job.attempts ?? 0) + 1;
|
|
1756
2033
|
const queuedAt = job.queuedAt ?? now;
|
|
1757
2034
|
const expired = now - queuedAt > exports.UPLOAD_TTL_MS;
|
|
1758
|
-
|
|
2035
|
+
const giveUp = attempts >= exports.MAX_UPLOAD_ATTEMPTS || expired || !deps.writeFileSync;
|
|
2036
|
+
if (giveUp) {
|
|
2037
|
+
if (deps.log) {
|
|
2038
|
+
try {
|
|
2039
|
+
const ageMs = now - queuedAt;
|
|
2040
|
+
deps.log({
|
|
2041
|
+
stage: "upload",
|
|
2042
|
+
sessionId,
|
|
2043
|
+
outcome: "gave-up",
|
|
2044
|
+
detail: `attempt=${attempts} age=${ageMs}ms`,
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
catch { /* log is best-effort */ }
|
|
2048
|
+
}
|
|
1759
2049
|
try {
|
|
1760
2050
|
deps.unlinkSync(jobFile);
|
|
1761
2051
|
}
|
|
1762
2052
|
catch { /* gone */ }
|
|
1763
2053
|
return;
|
|
1764
2054
|
}
|
|
2055
|
+
// Log the error before persisting the retry bump.
|
|
2056
|
+
if (deps.log) {
|
|
2057
|
+
try {
|
|
2058
|
+
const msg = err.message ?? String(err);
|
|
2059
|
+
// Extract http=<status> when the error message carries one (format used
|
|
2060
|
+
// by postJson: "HTTP <status>: <body>"). Truncate to 200 chars.
|
|
2061
|
+
const httpMatch = msg.match(/\bhttp[= ](\d{3})\b/i);
|
|
2062
|
+
const httpPart = httpMatch ? `http=${httpMatch[1]} ` : "";
|
|
2063
|
+
const detail = `${httpPart}${msg.slice(0, 200)} attempt=${attempts}`;
|
|
2064
|
+
deps.log({ stage: "upload", sessionId, outcome: "ERROR", detail });
|
|
2065
|
+
}
|
|
2066
|
+
catch { /* log is best-effort */ }
|
|
2067
|
+
}
|
|
2068
|
+
// The sweep hands us a CLAIMED path (`<job>.claim-<pid>`). Persist the
|
|
2069
|
+
// retry bump under the canonical name — the sweep only matches
|
|
2070
|
+
// `codiedev-capture-*.json`, so a bump left on the claimed path would
|
|
2071
|
+
// never be retried (a silently dropped capture).
|
|
2072
|
+
const canonical = jobFile.replace(/\.claim-\d+$/, "");
|
|
2073
|
+
const writeFn = deps.writeFileSync; // always set here: giveUp branch checked !deps.writeFileSync
|
|
1765
2074
|
try {
|
|
1766
|
-
|
|
2075
|
+
writeFn(canonical, JSON.stringify({ ...job, attempts, queuedAt }), { mode: 0o600 });
|
|
2076
|
+
if (canonical !== jobFile) {
|
|
2077
|
+
try {
|
|
2078
|
+
deps.unlinkSync(jobFile);
|
|
2079
|
+
}
|
|
2080
|
+
catch { /* gone */ }
|
|
2081
|
+
}
|
|
1767
2082
|
}
|
|
1768
2083
|
catch {
|
|
1769
2084
|
// couldn't persist the bump — drop it rather than risk a stuck file.
|
|
@@ -1774,3 +2089,27 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
1774
2089
|
}
|
|
1775
2090
|
}
|
|
1776
2091
|
}
|
|
2092
|
+
async function runDeferredUpload(jobFile, delayMs, deps) {
|
|
2093
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
2094
|
+
await sleep(delayMs);
|
|
2095
|
+
// Claim via rename. If the file is gone or already claimed, a newer turn's
|
|
2096
|
+
// upload superseded this one: log and exit silently.
|
|
2097
|
+
const claimed = `${jobFile}.claim-${process.pid}`;
|
|
2098
|
+
try {
|
|
2099
|
+
deps.renameSync(jobFile, claimed);
|
|
2100
|
+
}
|
|
2101
|
+
catch {
|
|
2102
|
+
if (deps.log) {
|
|
2103
|
+
try {
|
|
2104
|
+
// The job file is gone, so its body (and sessionId) is unreadable —
|
|
2105
|
+
// recover the session id from the per-session filename so doctor can
|
|
2106
|
+
// still tie this line to a session.
|
|
2107
|
+
const sid = /^codiedev-capture-(.+)\.json/.exec(path.basename(jobFile))?.[1];
|
|
2108
|
+
deps.log({ stage: "upload-deferred", sessionId: sid, outcome: "superseded" });
|
|
2109
|
+
}
|
|
2110
|
+
catch { /* log is best-effort */ }
|
|
2111
|
+
}
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
await runCaptureUpload(claimed, deps);
|
|
2115
|
+
}
|
package/package.json
CHANGED