@cat-factory/executor-harness 1.48.1 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/agent-runner.js +21 -4
- package/dist/pi-workspace.js +1 -0
- package/dist/transcript-retention.js +139 -0
- package/package.json +3 -3
- package/src/agent-runner.ts +29 -4
- package/src/pi-workspace.ts +1 -0
- package/src/transcript-retention.ts +150 -0
package/README.md
CHANGED
|
@@ -73,6 +73,8 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
|
|
|
73
73
|
| `src/bootstrap.ts` | The `/bootstrap` handler (clone-or-empty → adapt → reinit + force-push). |
|
|
74
74
|
| `src/blueprint.ts` | The `/blueprint` handler (decompose → render `blueprints/` → commit on branch). |
|
|
75
75
|
| `src/embed.ts` | Bundled assets/templates written into the workspace. |
|
|
76
|
+
| `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`) — talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
|
|
77
|
+
| `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
|
|
76
78
|
| `src/logger.ts` | Structured logging. |
|
|
77
79
|
|
|
78
80
|
## Runner lifecycle knobs
|
|
@@ -85,6 +87,8 @@ runner):
|
|
|
85
87
|
| `PORT` | `8080` | HTTP port the harness listens on. |
|
|
86
88
|
| `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
|
|
87
89
|
| `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
|
|
90
|
+
| `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
|
|
91
|
+
| `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content — though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
|
|
88
92
|
|
|
89
93
|
## Build / test
|
|
90
94
|
|
package/dist/agent-runner.js
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
6
6
|
import { redact, secretsToRedact } from './redact.js';
|
|
7
|
+
import { retainSessionTranscripts } from './transcript-retention.js';
|
|
7
8
|
function isObject(value) {
|
|
8
9
|
return typeof value === 'object' && value !== null;
|
|
9
10
|
}
|
|
@@ -296,9 +297,17 @@ export async function runClaudeCode(opts) {
|
|
|
296
297
|
};
|
|
297
298
|
}
|
|
298
299
|
finally {
|
|
299
|
-
|
|
300
|
-
|
|
300
|
+
if (configHome) {
|
|
301
|
+
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
302
|
+
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
303
|
+
// keeps the debugging artifact without leaking the token. Best-effort; never throws.
|
|
304
|
+
await retainSessionTranscripts(configHome, ['projects'], {
|
|
305
|
+
label: 'claude-code',
|
|
306
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
307
|
+
});
|
|
308
|
+
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
301
309
|
await rm(configHome, { recursive: true, force: true }).catch(() => { });
|
|
310
|
+
}
|
|
302
311
|
}
|
|
303
312
|
}
|
|
304
313
|
/** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
|
|
@@ -498,9 +507,17 @@ export async function runCodex(opts) {
|
|
|
498
507
|
};
|
|
499
508
|
}
|
|
500
509
|
finally {
|
|
501
|
-
|
|
502
|
-
|
|
510
|
+
if (codexHome) {
|
|
511
|
+
// Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
|
|
512
|
+
// home is deleted — the credential (`auth.json`) lives at the home root, never in
|
|
513
|
+
// `sessions/`, so this keeps the debugging artifact without leaking it. Best-effort.
|
|
514
|
+
await retainSessionTranscripts(codexHome, ['sessions'], {
|
|
515
|
+
label: 'codex',
|
|
516
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
517
|
+
});
|
|
518
|
+
// Never leave the decrypted credential on disk past the run.
|
|
503
519
|
await rm(codexHome, { recursive: true, force: true }).catch(() => { });
|
|
520
|
+
}
|
|
504
521
|
}
|
|
505
522
|
}
|
|
506
523
|
/**
|
package/dist/pi-workspace.js
CHANGED
|
@@ -146,6 +146,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
146
146
|
signal: opts.signal,
|
|
147
147
|
onActivity: opts.onActivity,
|
|
148
148
|
onProgress: opts.onProgress,
|
|
149
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
149
150
|
});
|
|
150
151
|
}
|
|
151
152
|
if (!spec.proxyBaseUrl || !spec.sessionToken) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
// Session-transcript retention for the subscription harnesses (Claude Code / Codex).
|
|
5
|
+
//
|
|
6
|
+
// Both runners create an ISOLATED, per-run config home so the leased OAuth credential never
|
|
7
|
+
// lands in the cloned checkout, then delete that home in `finally`. The CLIs also write their
|
|
8
|
+
// per-turn session transcripts INSIDE that home (`$CLAUDE_CONFIG_DIR/projects/…` for Claude
|
|
9
|
+
// Code, `$CODEX_HOME/sessions/…` for Codex), so deleting the home also erases the exact
|
|
10
|
+
// per-call detail needed to debug a finished run.
|
|
11
|
+
//
|
|
12
|
+
// This module lifts ONLY the transcript subtree out of the home BEFORE it is deleted, into a
|
|
13
|
+
// retention root, and prunes retained transcripts on a short TTL. The credential lives at the
|
|
14
|
+
// home ROOT (`.claude.json` / `auth.json`), never inside `projects/` / `sessions/`, so moving
|
|
15
|
+
// just those subdirs keeps the debugging artifact while the existing `rm(home)` still removes
|
|
16
|
+
// the credential — the credential-safety property is preserved.
|
|
17
|
+
//
|
|
18
|
+
// It is meaningful only where the container filesystem outlives the job (the reused local
|
|
19
|
+
// warm-pool container, whose next run's sweep honours the TTL); on a per-run cloud container
|
|
20
|
+
// torn down with the job it is a harmless no-op. Best-effort throughout: a retention failure
|
|
21
|
+
// must NEVER fail an otherwise-successful run.
|
|
22
|
+
/** Default retention window: 3 days. Overridable via `HARNESS_TRANSCRIPT_TTL_MS`. */
|
|
23
|
+
const DEFAULT_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
|
24
|
+
/**
|
|
25
|
+
* A marker file dropped into every retention dir THIS module creates. The pruner deletes ONLY
|
|
26
|
+
* dirs carrying it, so pointing `HARNESS_TRANSCRIPT_ROOT` at a shared (non-dedicated) directory
|
|
27
|
+
* can never `rm -rf` unrelated sibling content — we only ever sweep our own retained transcripts.
|
|
28
|
+
*/
|
|
29
|
+
export const RETENTION_MARKER = '.cf-retained';
|
|
30
|
+
/** The retention root (one dir per retained home underneath it). Overridable for operators. */
|
|
31
|
+
function retentionRoot() {
|
|
32
|
+
const override = process.env.HARNESS_TRANSCRIPT_ROOT?.trim();
|
|
33
|
+
return override && override.length > 0 ? override : join(tmpdir(), 'cf-agent-transcripts');
|
|
34
|
+
}
|
|
35
|
+
/** The retention TTL in ms, from the env override when it's a positive finite number. */
|
|
36
|
+
function ttlMs() {
|
|
37
|
+
const raw = Number(process.env.HARNESS_TRANSCRIPT_TTL_MS);
|
|
38
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Move the named transcript `subdirs` out of the credential-bearing config `home` into the
|
|
42
|
+
* retention root (so the caller's subsequent `rm(home)` can't take them), then prune retained
|
|
43
|
+
* transcripts older than the TTL. Both steps are best-effort: any failure is swallowed (logged
|
|
44
|
+
* at debug) so this can never fail an otherwise-successful run. Returns the destination dir when
|
|
45
|
+
* something was retained (for logging/tests), else `undefined`.
|
|
46
|
+
*/
|
|
47
|
+
export async function retainSessionTranscripts(home, subdirs, options = {}) {
|
|
48
|
+
const { label, log } = options;
|
|
49
|
+
const root = retentionRoot();
|
|
50
|
+
// A filesystem-safe, sortable per-home dir name: an ISO stamp (colons/dots → dashes) + the
|
|
51
|
+
// home's basename (already unique — `mkdtemp` seeded).
|
|
52
|
+
const dest = join(root, `${new Date().toISOString().replace(/[:.]/g, '-')}-${basename(home)}`);
|
|
53
|
+
let moved = 0;
|
|
54
|
+
try {
|
|
55
|
+
for (const sub of subdirs) {
|
|
56
|
+
const from = join(home, sub);
|
|
57
|
+
try {
|
|
58
|
+
await stat(from);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue; // the CLI never wrote this subdir this run — nothing to retain
|
|
62
|
+
}
|
|
63
|
+
if (moved === 0)
|
|
64
|
+
await ensureRetentionDir(dest);
|
|
65
|
+
await moveDir(from, join(dest, sub));
|
|
66
|
+
moved += 1;
|
|
67
|
+
}
|
|
68
|
+
if (moved > 0)
|
|
69
|
+
log?.info('retained session transcripts', { label, dest, subdirs: moved });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
log?.debug('failed to retain session transcripts', {
|
|
73
|
+
label,
|
|
74
|
+
err: err instanceof Error ? err.message : String(err),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Prune regardless of whether this run retained anything — a run that moved nothing still gets
|
|
78
|
+
// to sweep the backlog left by earlier runs on a reused container.
|
|
79
|
+
await pruneRetentionRoot(root, ttlMs(), log);
|
|
80
|
+
return moved > 0 ? dest : undefined;
|
|
81
|
+
}
|
|
82
|
+
/** Create a retention dir and stamp it with the ownership marker (see {@link RETENTION_MARKER}). */
|
|
83
|
+
async function ensureRetentionDir(dest) {
|
|
84
|
+
await mkdir(dest, { recursive: true });
|
|
85
|
+
await writeFile(join(dest, RETENTION_MARKER), '');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Move `from` → `to`, preferring a cheap same-filesystem `rename`. When the retention root is on
|
|
89
|
+
* a DIFFERENT device than the config home (an operator override of `HARNESS_TRANSCRIPT_ROOT`),
|
|
90
|
+
* `rename` fails with `EXDEV` — fall back to a recursive copy + remove so the transcript is still
|
|
91
|
+
* lifted out before the caller deletes the home, rather than being silently lost.
|
|
92
|
+
*/
|
|
93
|
+
async function moveDir(from, to) {
|
|
94
|
+
try {
|
|
95
|
+
await rename(from, to);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err?.code !== 'EXDEV')
|
|
99
|
+
throw err;
|
|
100
|
+
await cp(from, to, { recursive: true });
|
|
101
|
+
await rm(from, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Delete retained-transcript dirs whose mtime is older than `maxAgeMs`. Only dirs carrying the
|
|
106
|
+
* retention marker are candidates — foreign content under a shared retention root is never
|
|
107
|
+
* touched. Best-effort per entry (a concurrent sweep or a vanished dir must not abort the loop).
|
|
108
|
+
*/
|
|
109
|
+
async function pruneRetentionRoot(root, maxAgeMs, log) {
|
|
110
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = await readdir(root);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return; // the root doesn't exist yet (nothing ever retained) — nothing to prune
|
|
117
|
+
}
|
|
118
|
+
for (const name of entries) {
|
|
119
|
+
const path = join(root, name);
|
|
120
|
+
try {
|
|
121
|
+
const info = await stat(path);
|
|
122
|
+
if (!info.isDirectory() || info.mtimeMs >= cutoff)
|
|
123
|
+
continue;
|
|
124
|
+
// Ownership gate: only sweep dirs WE created (they carry the marker). This makes a
|
|
125
|
+
// shared/non-dedicated `HARNESS_TRANSCRIPT_ROOT` safe — unrelated dirs are left alone.
|
|
126
|
+
try {
|
|
127
|
+
await stat(join(path, RETENTION_MARKER));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
continue; // not one of ours — never delete it
|
|
131
|
+
}
|
|
132
|
+
await rm(path, { recursive: true, force: true });
|
|
133
|
+
log?.debug('pruned expired session transcripts', { path });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// best-effort per entry — a concurrent sweep or a vanished dir must not abort the loop
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.0",
|
|
4
4
|
"description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"hono": "^4.12.30",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/server": "0.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.138.7",
|
|
30
|
+
"@cat-factory/spend": "0.12.62"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { spawn } from 'node:child_process'
|
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { homedir, tmpdir } from 'node:os'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
|
+
import type { Logger } from './logger.js'
|
|
5
6
|
import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
|
|
6
7
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
7
8
|
import { redact, secretsToRedact } from './redact.js'
|
|
9
|
+
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
8
10
|
|
|
9
11
|
// The alternate (subscription) harness runners. The Pi harness reaches models
|
|
10
12
|
// through the LLM proxy with a model-locked session token; the Claude Code and
|
|
@@ -70,6 +72,11 @@ export interface SubscriptionRunOptions {
|
|
|
70
72
|
onActivity?: () => void
|
|
71
73
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
72
74
|
onProgress?: (progress: TodoProgress) => void
|
|
75
|
+
/**
|
|
76
|
+
* The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
|
|
77
|
+
* session-transcript path is logged for the run when the isolated config home is torn down.
|
|
78
|
+
*/
|
|
79
|
+
log?: Logger
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -400,8 +407,17 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
400
407
|
...(calls.length ? { callMetrics: calls } : {}),
|
|
401
408
|
}
|
|
402
409
|
} finally {
|
|
403
|
-
|
|
404
|
-
|
|
410
|
+
if (configHome) {
|
|
411
|
+
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
412
|
+
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
413
|
+
// keeps the debugging artifact without leaking the token. Best-effort; never throws.
|
|
414
|
+
await retainSessionTranscripts(configHome, ['projects'], {
|
|
415
|
+
label: 'claude-code',
|
|
416
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
417
|
+
})
|
|
418
|
+
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
419
|
+
await rm(configHome, { recursive: true, force: true }).catch(() => {})
|
|
420
|
+
}
|
|
405
421
|
}
|
|
406
422
|
}
|
|
407
423
|
|
|
@@ -620,8 +636,17 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
620
636
|
...(calls.length ? { callMetrics: calls } : {}),
|
|
621
637
|
}
|
|
622
638
|
} finally {
|
|
623
|
-
|
|
624
|
-
|
|
639
|
+
if (codexHome) {
|
|
640
|
+
// Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
|
|
641
|
+
// home is deleted — the credential (`auth.json`) lives at the home root, never in
|
|
642
|
+
// `sessions/`, so this keeps the debugging artifact without leaking it. Best-effort.
|
|
643
|
+
await retainSessionTranscripts(codexHome, ['sessions'], {
|
|
644
|
+
label: 'codex',
|
|
645
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
646
|
+
})
|
|
647
|
+
// Never leave the decrypted credential on disk past the run.
|
|
648
|
+
await rm(codexHome, { recursive: true, force: true }).catch(() => {})
|
|
649
|
+
}
|
|
625
650
|
}
|
|
626
651
|
}
|
|
627
652
|
|
package/src/pi-workspace.ts
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
import type { Logger } from './logger.js'
|
|
5
|
+
|
|
6
|
+
// Session-transcript retention for the subscription harnesses (Claude Code / Codex).
|
|
7
|
+
//
|
|
8
|
+
// Both runners create an ISOLATED, per-run config home so the leased OAuth credential never
|
|
9
|
+
// lands in the cloned checkout, then delete that home in `finally`. The CLIs also write their
|
|
10
|
+
// per-turn session transcripts INSIDE that home (`$CLAUDE_CONFIG_DIR/projects/…` for Claude
|
|
11
|
+
// Code, `$CODEX_HOME/sessions/…` for Codex), so deleting the home also erases the exact
|
|
12
|
+
// per-call detail needed to debug a finished run.
|
|
13
|
+
//
|
|
14
|
+
// This module lifts ONLY the transcript subtree out of the home BEFORE it is deleted, into a
|
|
15
|
+
// retention root, and prunes retained transcripts on a short TTL. The credential lives at the
|
|
16
|
+
// home ROOT (`.claude.json` / `auth.json`), never inside `projects/` / `sessions/`, so moving
|
|
17
|
+
// just those subdirs keeps the debugging artifact while the existing `rm(home)` still removes
|
|
18
|
+
// the credential — the credential-safety property is preserved.
|
|
19
|
+
//
|
|
20
|
+
// It is meaningful only where the container filesystem outlives the job (the reused local
|
|
21
|
+
// warm-pool container, whose next run's sweep honours the TTL); on a per-run cloud container
|
|
22
|
+
// torn down with the job it is a harmless no-op. Best-effort throughout: a retention failure
|
|
23
|
+
// must NEVER fail an otherwise-successful run.
|
|
24
|
+
|
|
25
|
+
/** Default retention window: 3 days. Overridable via `HARNESS_TRANSCRIPT_TTL_MS`. */
|
|
26
|
+
const DEFAULT_TTL_MS = 3 * 24 * 60 * 60 * 1000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A marker file dropped into every retention dir THIS module creates. The pruner deletes ONLY
|
|
30
|
+
* dirs carrying it, so pointing `HARNESS_TRANSCRIPT_ROOT` at a shared (non-dedicated) directory
|
|
31
|
+
* can never `rm -rf` unrelated sibling content — we only ever sweep our own retained transcripts.
|
|
32
|
+
*/
|
|
33
|
+
export const RETENTION_MARKER = '.cf-retained'
|
|
34
|
+
|
|
35
|
+
/** The retention root (one dir per retained home underneath it). Overridable for operators. */
|
|
36
|
+
function retentionRoot(): string {
|
|
37
|
+
const override = process.env.HARNESS_TRANSCRIPT_ROOT?.trim()
|
|
38
|
+
return override && override.length > 0 ? override : join(tmpdir(), 'cf-agent-transcripts')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The retention TTL in ms, from the env override when it's a positive finite number. */
|
|
42
|
+
function ttlMs(): number {
|
|
43
|
+
const raw = Number(process.env.HARNESS_TRANSCRIPT_TTL_MS)
|
|
44
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RetainOptions {
|
|
48
|
+
/** A short label for the run/harness, folded into the retention log line. */
|
|
49
|
+
label?: string
|
|
50
|
+
/** The per-job child logger, so the retained path is logged with the run's correlation fields. */
|
|
51
|
+
log?: Logger
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Move the named transcript `subdirs` out of the credential-bearing config `home` into the
|
|
56
|
+
* retention root (so the caller's subsequent `rm(home)` can't take them), then prune retained
|
|
57
|
+
* transcripts older than the TTL. Both steps are best-effort: any failure is swallowed (logged
|
|
58
|
+
* at debug) so this can never fail an otherwise-successful run. Returns the destination dir when
|
|
59
|
+
* something was retained (for logging/tests), else `undefined`.
|
|
60
|
+
*/
|
|
61
|
+
export async function retainSessionTranscripts(
|
|
62
|
+
home: string,
|
|
63
|
+
subdirs: string[],
|
|
64
|
+
options: RetainOptions = {},
|
|
65
|
+
): Promise<string | undefined> {
|
|
66
|
+
const { label, log } = options
|
|
67
|
+
const root = retentionRoot()
|
|
68
|
+
// A filesystem-safe, sortable per-home dir name: an ISO stamp (colons/dots → dashes) + the
|
|
69
|
+
// home's basename (already unique — `mkdtemp` seeded).
|
|
70
|
+
const dest = join(root, `${new Date().toISOString().replace(/[:.]/g, '-')}-${basename(home)}`)
|
|
71
|
+
let moved = 0
|
|
72
|
+
try {
|
|
73
|
+
for (const sub of subdirs) {
|
|
74
|
+
const from = join(home, sub)
|
|
75
|
+
try {
|
|
76
|
+
await stat(from)
|
|
77
|
+
} catch {
|
|
78
|
+
continue // the CLI never wrote this subdir this run — nothing to retain
|
|
79
|
+
}
|
|
80
|
+
if (moved === 0) await ensureRetentionDir(dest)
|
|
81
|
+
await moveDir(from, join(dest, sub))
|
|
82
|
+
moved += 1
|
|
83
|
+
}
|
|
84
|
+
if (moved > 0) log?.info('retained session transcripts', { label, dest, subdirs: moved })
|
|
85
|
+
} catch (err) {
|
|
86
|
+
log?.debug('failed to retain session transcripts', {
|
|
87
|
+
label,
|
|
88
|
+
err: err instanceof Error ? err.message : String(err),
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
// Prune regardless of whether this run retained anything — a run that moved nothing still gets
|
|
92
|
+
// to sweep the backlog left by earlier runs on a reused container.
|
|
93
|
+
await pruneRetentionRoot(root, ttlMs(), log)
|
|
94
|
+
return moved > 0 ? dest : undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Create a retention dir and stamp it with the ownership marker (see {@link RETENTION_MARKER}). */
|
|
98
|
+
async function ensureRetentionDir(dest: string): Promise<void> {
|
|
99
|
+
await mkdir(dest, { recursive: true })
|
|
100
|
+
await writeFile(join(dest, RETENTION_MARKER), '')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Move `from` → `to`, preferring a cheap same-filesystem `rename`. When the retention root is on
|
|
105
|
+
* a DIFFERENT device than the config home (an operator override of `HARNESS_TRANSCRIPT_ROOT`),
|
|
106
|
+
* `rename` fails with `EXDEV` — fall back to a recursive copy + remove so the transcript is still
|
|
107
|
+
* lifted out before the caller deletes the home, rather than being silently lost.
|
|
108
|
+
*/
|
|
109
|
+
async function moveDir(from: string, to: string): Promise<void> {
|
|
110
|
+
try {
|
|
111
|
+
await rename(from, to)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if ((err as NodeJS.ErrnoException)?.code !== 'EXDEV') throw err
|
|
114
|
+
await cp(from, to, { recursive: true })
|
|
115
|
+
await rm(from, { recursive: true, force: true })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Delete retained-transcript dirs whose mtime is older than `maxAgeMs`. Only dirs carrying the
|
|
121
|
+
* retention marker are candidates — foreign content under a shared retention root is never
|
|
122
|
+
* touched. Best-effort per entry (a concurrent sweep or a vanished dir must not abort the loop).
|
|
123
|
+
*/
|
|
124
|
+
async function pruneRetentionRoot(root: string, maxAgeMs: number, log?: Logger): Promise<void> {
|
|
125
|
+
const cutoff = Date.now() - maxAgeMs
|
|
126
|
+
let entries: string[]
|
|
127
|
+
try {
|
|
128
|
+
entries = await readdir(root)
|
|
129
|
+
} catch {
|
|
130
|
+
return // the root doesn't exist yet (nothing ever retained) — nothing to prune
|
|
131
|
+
}
|
|
132
|
+
for (const name of entries) {
|
|
133
|
+
const path = join(root, name)
|
|
134
|
+
try {
|
|
135
|
+
const info = await stat(path)
|
|
136
|
+
if (!info.isDirectory() || info.mtimeMs >= cutoff) continue
|
|
137
|
+
// Ownership gate: only sweep dirs WE created (they carry the marker). This makes a
|
|
138
|
+
// shared/non-dedicated `HARNESS_TRANSCRIPT_ROOT` safe — unrelated dirs are left alone.
|
|
139
|
+
try {
|
|
140
|
+
await stat(join(path, RETENTION_MARKER))
|
|
141
|
+
} catch {
|
|
142
|
+
continue // not one of ours — never delete it
|
|
143
|
+
}
|
|
144
|
+
await rm(path, { recursive: true, force: true })
|
|
145
|
+
log?.debug('pruned expired session transcripts', { path })
|
|
146
|
+
} catch {
|
|
147
|
+
// best-effort per entry — a concurrent sweep or a vanished dir must not abort the loop
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|