@swimmingliu/autovpn 1.4.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 (40) hide show
  1. package/README.md +102 -0
  2. package/bin/autovpn.mjs +5 -0
  3. package/dist/artifacts/list.js +99 -0
  4. package/dist/artifacts/preview.js +85 -0
  5. package/dist/backend/node-backend.js +194 -0
  6. package/dist/backend/python-backend.js +242 -0
  7. package/dist/backend/select-backend.js +12 -0
  8. package/dist/backend/types.js +1 -0
  9. package/dist/cli/commands/index.js +143 -0
  10. package/dist/cli/errors.js +7 -0
  11. package/dist/cli/global-options.js +29 -0
  12. package/dist/cli/main.js +231 -0
  13. package/dist/cli/native-commands.js +215 -0
  14. package/dist/cli/output.js +31 -0
  15. package/dist/config/profile.js +80 -0
  16. package/dist/doctor/checks.js +184 -0
  17. package/dist/events/schema.js +45 -0
  18. package/dist/jobs/commands.js +159 -0
  19. package/dist/jobs/logs.js +48 -0
  20. package/dist/jobs/process.js +75 -0
  21. package/dist/jobs/read.js +282 -0
  22. package/dist/jobs/store.js +115 -0
  23. package/dist/pipeline/availability.js +679 -0
  24. package/dist/pipeline/dedupe.js +104 -0
  25. package/dist/pipeline/deploy.js +1103 -0
  26. package/dist/pipeline/extract.js +259 -0
  27. package/dist/pipeline/obfuscate.js +203 -0
  28. package/dist/pipeline/orchestrator.js +1014 -0
  29. package/dist/pipeline/postprocess.js +214 -0
  30. package/dist/pipeline/proxy-runtime.js +228 -0
  31. package/dist/pipeline/render.js +91 -0
  32. package/dist/pipeline/speedtest.js +579 -0
  33. package/dist/runtime/env.js +35 -0
  34. package/dist/runtime/paths.js +66 -0
  35. package/dist/runtime/redaction.js +56 -0
  36. package/lib/cache.mjs +14 -0
  37. package/lib/errors.mjs +11 -0
  38. package/lib/install-python-cli.mjs +63 -0
  39. package/lib/runner.mjs +113 -0
  40. package/package.json +39 -0
@@ -0,0 +1,159 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn as defaultSpawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { publicJobPayload } from './read.js';
6
+ import { createJobStore } from './store.js';
7
+ import { processMatchesJob as defaultProcessMatchesJob, terminateProcessGroup } from './process.js';
8
+ async function defaultResolvePythonCli(env) {
9
+ // @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
10
+ const runner = await import('../../lib/runner.mjs');
11
+ return runner.resolveOrInstallPythonCli({ env });
12
+ }
13
+ function defaultResolveNodeCli() {
14
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
15
+ return { command: process.execPath, args: [path.join(packageRoot, 'bin', 'autovpn.mjs')] };
16
+ }
17
+ function wantsNodeWorker(env) {
18
+ return String(env.AUTOVPN_BACKEND ?? '').trim().toLowerCase() !== 'python';
19
+ }
20
+ async function resolveDetachedWorker(env, options) {
21
+ if (wantsNodeWorker(env)) {
22
+ return defaultResolveNodeCli();
23
+ }
24
+ return options.resolvePythonCli ? await options.resolvePythonCli() : await defaultResolvePythonCli(env);
25
+ }
26
+ function pushFlag(argv, enabled, flag) {
27
+ if (enabled)
28
+ argv.push(flag);
29
+ }
30
+ function spawnDetached(command, args, job, options) {
31
+ const stdoutFd = fs.openSync(String(job.stdout_log), 'a');
32
+ const stderrFd = fs.openSync(String(job.stderr_log), 'a');
33
+ try {
34
+ const child = (options.spawn ?? defaultSpawn)(command, args, {
35
+ cwd: options.cwd ?? job.project_root,
36
+ env: options.env ?? process.env,
37
+ detached: process.platform !== 'win32',
38
+ stdio: ['ignore', stdoutFd, stderrFd]
39
+ });
40
+ child.unref?.();
41
+ return child;
42
+ }
43
+ finally {
44
+ fs.closeSync(stdoutFd);
45
+ fs.closeSync(stderrFd);
46
+ }
47
+ }
48
+ export async function startDetachedRun(command, options = {}) {
49
+ const outputFormat = command.outputFormat ?? 'jsonl';
50
+ const jobStore = createJobStore(command.projectRoot, options);
51
+ const runArgs = ['run', '--project-root', command.projectRoot, '--output', outputFormat];
52
+ const resolved = await resolveDetachedWorker(options.env ?? process.env, options);
53
+ const job = jobStore.createRunningJob({
54
+ kind: 'run',
55
+ command: [],
56
+ pid: 0,
57
+ options: {
58
+ source_job_id: command.sourceJobId ?? '',
59
+ resume_latest: Boolean(command.resumeLatest),
60
+ skip_deploy: Boolean(command.skipDeploy),
61
+ skip_verify: Boolean(command.skipVerify),
62
+ output_format: outputFormat
63
+ }
64
+ });
65
+ runArgs.push('--event-log', String(job.event_log), '--human-log', String(job.human_log));
66
+ pushFlag(runArgs, command.resumeLatest, '--resume-latest');
67
+ pushFlag(runArgs, command.skipDeploy, '--skip-deploy');
68
+ pushFlag(runArgs, command.skipVerify, '--skip-verify');
69
+ job.command = [resolved.command, ...resolved.args, ...runArgs];
70
+ const child = spawnDetached(resolved.command, [...resolved.args, ...runArgs], job, options);
71
+ job.pid = Number(child.pid ?? 0);
72
+ job.pgid = Number(child.pid ?? 0);
73
+ return jobStore.writeJob(job);
74
+ }
75
+ export async function startDetachedResume(command, options = {}) {
76
+ const outputFormat = command.outputFormat ?? 'jsonl';
77
+ const jobStore = createJobStore(command.projectRoot, options);
78
+ const resolved = await resolveDetachedWorker(options.env ?? process.env, options);
79
+ const job = jobStore.createRunningJob({
80
+ kind: 'resume',
81
+ command: [],
82
+ pid: 0,
83
+ options: { source_job_id: command.sourceJobId, session_dir: command.sessionDir, output_format: outputFormat },
84
+ resumeFrom: command.sessionDir
85
+ });
86
+ const pythonArgs = [
87
+ 'resume',
88
+ 'pipeline',
89
+ '--project-root',
90
+ command.projectRoot,
91
+ '--session',
92
+ command.sessionDir,
93
+ '--output',
94
+ outputFormat,
95
+ '--event-log',
96
+ String(job.event_log),
97
+ '--human-log',
98
+ String(job.human_log)
99
+ ];
100
+ job.command = [resolved.command, ...resolved.args, ...pythonArgs];
101
+ const child = spawnDetached(resolved.command, [...resolved.args, ...pythonArgs], job, options);
102
+ job.pid = Number(child.pid ?? 0);
103
+ job.pgid = Number(child.pid ?? 0);
104
+ return jobStore.writeJob(job);
105
+ }
106
+ export async function startDetachedRetry(command, options = {}) {
107
+ const outputFormat = command.outputFormat ?? 'jsonl';
108
+ const jobStore = createJobStore(command.projectRoot, options);
109
+ const resolved = await resolveDetachedWorker(options.env ?? process.env, options);
110
+ const retry = { source_artifact_dir: command.artifactDir, stage: command.stage };
111
+ const job = jobStore.createRunningJob({
112
+ kind: 'retry',
113
+ command: [],
114
+ pid: 0,
115
+ options: { artifact_dir: command.artifactDir, stage: command.stage, output_format: outputFormat },
116
+ retry
117
+ });
118
+ const pythonArgs = [
119
+ 'retry-stage',
120
+ '--project-root',
121
+ command.projectRoot,
122
+ '--artifact-dir',
123
+ command.artifactDir,
124
+ '--stage',
125
+ command.stage,
126
+ '--output',
127
+ outputFormat,
128
+ '--event-log',
129
+ String(job.event_log),
130
+ '--human-log',
131
+ String(job.human_log)
132
+ ];
133
+ job.command = [resolved.command, ...resolved.args, ...pythonArgs];
134
+ const child = spawnDetached(resolved.command, [...resolved.args, ...pythonArgs], job, options);
135
+ job.pid = Number(child.pid ?? 0);
136
+ job.pgid = Number(child.pid ?? 0);
137
+ return jobStore.writeJob(job);
138
+ }
139
+ export async function stopManagedJob(projectRoot, jobId, options = {}) {
140
+ const store = createJobStore(projectRoot, options);
141
+ const job = store.loadJob(jobId);
142
+ job.status = 'stopping';
143
+ job.stop_requested_at = options.now?.() ?? new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
144
+ store.writeJob(job);
145
+ const pid = Number(job.pgid || job.pid || 0);
146
+ const matcher = options.processMatchesJob ?? defaultProcessMatchesJob;
147
+ if (pid > 0 && !matcher(pid, Array.isArray(job.command) ? job.command.map(String) : [])) {
148
+ throw new Error(`refusing to stop pid ${pid}: command does not match AutoVPN job`);
149
+ }
150
+ await terminateProcessGroup(pid, options);
151
+ job.status = 'stopped';
152
+ job.finished_at = options.now?.() ?? new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
153
+ job.exit_code = 1;
154
+ job.signal = 'SIGTERM';
155
+ return store.writeJob(job);
156
+ }
157
+ export function publicStartedPayload(job) {
158
+ return { ok: true, ...publicJobPayload(job) };
159
+ }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import { loadJob, tailLog } from './read.js';
3
+ import { readOptionValue } from '../runtime/paths.js';
4
+ function logPathFor(job, argv) {
5
+ return String((readOptionValue(argv, '--format') === 'jsonl' ? job.event_log : job.human_log) ?? '');
6
+ }
7
+ function readFromOffset(filePath, offset) {
8
+ if (!fs.existsSync(filePath)) {
9
+ return { chunk: '', offset };
10
+ }
11
+ const fd = fs.openSync(filePath, 'r');
12
+ try {
13
+ const stat = fs.fstatSync(fd);
14
+ if (stat.size <= offset) {
15
+ return { chunk: '', offset: stat.size };
16
+ }
17
+ const buffer = Buffer.alloc(stat.size - offset);
18
+ fs.readSync(fd, buffer, 0, buffer.length, offset);
19
+ return { chunk: buffer.toString('utf8'), offset: stat.size };
20
+ }
21
+ finally {
22
+ fs.closeSync(fd);
23
+ }
24
+ }
25
+ function defaultSleep(ms) {
26
+ return new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
28
+ export async function* followLog(projectRoot, jobId, argv, options = {}) {
29
+ const env = options.env ?? process.env;
30
+ const sleep = options.sleep ?? defaultSleep;
31
+ const pollMs = options.pollMs ?? 1000;
32
+ const initial = tailLog(projectRoot, jobId, argv, env);
33
+ if (initial) {
34
+ yield initial;
35
+ }
36
+ let job = loadJob(projectRoot, jobId, env);
37
+ const filePath = logPathFor(job, argv);
38
+ let offset = fs.existsSync(filePath) ? Buffer.byteLength(fs.readFileSync(filePath, 'utf8')) : 0;
39
+ while (['running', 'stopping'].includes(String(job.status ?? ''))) {
40
+ await sleep(pollMs);
41
+ const update = readFromOffset(filePath, offset);
42
+ if (update.chunk) {
43
+ yield update.chunk;
44
+ }
45
+ offset = update.offset;
46
+ job = loadJob(projectRoot, jobId, env);
47
+ }
48
+ }
@@ -0,0 +1,75 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync as defaultSpawnSync } from 'node:child_process';
4
+ export function processMatchesJob(pid, command) {
5
+ const cmdlinePath = path.join('/proc', String(pid), 'cmdline');
6
+ if (!fs.existsSync(cmdlinePath)) {
7
+ return true;
8
+ }
9
+ try {
10
+ const cmdline = fs.readFileSync(cmdlinePath).toString('utf8').replaceAll('\0', ' ');
11
+ const markers = ['vpn_automation.backend'];
12
+ if (command.length > 0) {
13
+ markers.push(path.basename(String(command[0])));
14
+ }
15
+ return markers.some((marker) => marker.length > 0 && cmdline.includes(marker));
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function defaultIsAlive(pid) {
22
+ if (pid <= 0)
23
+ return false;
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch (error) {
29
+ return error.code === 'EPERM';
30
+ }
31
+ }
32
+ function defaultSignalProcess(target, signal, options) {
33
+ if ((options.platform ?? process.platform) === 'win32') {
34
+ const tree = signal === 'SIGKILL' ? ['/pid', String(target), '/t', '/f'] : ['/pid', String(target), '/t'];
35
+ (options.spawnSync ?? defaultSpawnSync)('taskkill', tree, { stdio: 'ignore' });
36
+ return;
37
+ }
38
+ process.kill(target, signal);
39
+ }
40
+ function defaultSleep(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+ export function signalTarget(pid, platform = process.platform) {
44
+ return platform === 'win32' ? pid : -pid;
45
+ }
46
+ export async function terminateProcessGroup(pid, options = {}) {
47
+ if (pid <= 0)
48
+ return;
49
+ const platform = options.platform ?? process.platform;
50
+ const isAlive = options.isAlive ?? defaultIsAlive;
51
+ const signalProcess = options.signalProcess;
52
+ const sleep = options.sleep ?? defaultSleep;
53
+ const timeoutMs = options.timeoutMs ?? 4000;
54
+ const target = signalTarget(pid, platform);
55
+ if (!isAlive(pid))
56
+ return;
57
+ if (signalProcess) {
58
+ signalProcess(target, 'SIGTERM');
59
+ }
60
+ else {
61
+ defaultSignalProcess(target, 'SIGTERM', options);
62
+ }
63
+ const deadline = Date.now() + timeoutMs;
64
+ while (Date.now() < deadline && isAlive(pid)) {
65
+ await sleep(100);
66
+ }
67
+ if (isAlive(pid)) {
68
+ if (signalProcess) {
69
+ signalProcess(target, 'SIGKILL');
70
+ }
71
+ else {
72
+ defaultSignalProcess(target, 'SIGKILL', options);
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { resolveProfilePath, readOptionValue } from '../runtime/paths.js';
5
+ import { redactText } from '../runtime/redaction.js';
6
+ function jobsRoot(projectRoot, env = process.env) {
7
+ return path.join(path.dirname(resolveProfilePath(projectRoot, env)), 'jobs');
8
+ }
9
+ function readJson(filePath) {
10
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
11
+ }
12
+ function nowIso() {
13
+ return new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
14
+ }
15
+ function processAlive(pid) {
16
+ if (pid <= 0) {
17
+ return false;
18
+ }
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ return error.code === 'EPERM';
25
+ }
26
+ }
27
+ function lastJsonEvents(filePath) {
28
+ if (!fs.existsSync(filePath)) {
29
+ return [];
30
+ }
31
+ return fs.readFileSync(filePath, 'utf8').split(/\r?\n/).flatMap((line) => {
32
+ if (!line.trim()) {
33
+ return [];
34
+ }
35
+ try {
36
+ return [JSON.parse(line)];
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ });
42
+ }
43
+ function reconcileFromPipelineReport(job) {
44
+ const artifactDir = String(job.artifact_dir ?? '');
45
+ if (!artifactDir) {
46
+ return undefined;
47
+ }
48
+ const reportPath = path.join(artifactDir, 'pipeline_report.json');
49
+ if (!fs.existsSync(reportPath)) {
50
+ return undefined;
51
+ }
52
+ try {
53
+ const report = readJson(reportPath);
54
+ const runStatus = String(report.run_status ?? '');
55
+ if (!['success', 'failed', 'stopped'].includes(runStatus)) {
56
+ return undefined;
57
+ }
58
+ return {
59
+ status: runStatus,
60
+ finished_at: job.finished_at || nowIso(),
61
+ exit_code: runStatus === 'success' ? 0 : 1,
62
+ last_error: redactText(String(report.error ?? job.last_error ?? ''))
63
+ };
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ function reconcileFromRunDb(job) {
70
+ const artifactDir = String(job.artifact_dir ?? '');
71
+ if (!artifactDir) {
72
+ return undefined;
73
+ }
74
+ const runDbPath = path.join(artifactDir, 'run.db');
75
+ if (!fs.existsSync(runDbPath)) {
76
+ return undefined;
77
+ }
78
+ try {
79
+ const require = createRequire(import.meta.url);
80
+ const sqlite = require('node:sqlite');
81
+ const db = new sqlite.DatabaseSync(runDbPath);
82
+ try {
83
+ const row = db.prepare('SELECT status FROM runs ORDER BY run_id DESC LIMIT 1').get();
84
+ const runStatus = String(row?.status ?? '');
85
+ if (!['success', 'failed', 'stopped'].includes(runStatus)) {
86
+ return undefined;
87
+ }
88
+ return {
89
+ status: runStatus,
90
+ finished_at: job.finished_at || nowIso(),
91
+ exit_code: runStatus === 'success' ? 0 : 1
92
+ };
93
+ }
94
+ finally {
95
+ db.close();
96
+ }
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ function writeJob(job) {
103
+ const jobFile = String(job.job_file ?? '');
104
+ if (!jobFile) {
105
+ return;
106
+ }
107
+ job.updated_at = nowIso();
108
+ fs.writeFileSync(jobFile, `${JSON.stringify(job, null, 2)}\n`, 'utf8');
109
+ }
110
+ function reconcileJob(job) {
111
+ const events = lastJsonEvents(String(job.event_log ?? ''));
112
+ for (const event of events) {
113
+ job.last_event_at = nowIso();
114
+ if (event.type === 'run_started' && event.artifact_dir) {
115
+ job.artifact_dir = String(event.artifact_dir);
116
+ }
117
+ if (event.type === 'summary') {
118
+ job.artifact_dir = String(event.artifact_dir || job.artifact_dir || '');
119
+ const runStatus = String(event.run_status || '');
120
+ if (['success', 'failed', 'stopped'].includes(runStatus)) {
121
+ job.status = runStatus;
122
+ job.finished_at = job.finished_at || nowIso();
123
+ job.exit_code = runStatus === 'success' ? 0 : 1;
124
+ }
125
+ if (event.error) {
126
+ job.last_error = String(event.error);
127
+ }
128
+ }
129
+ if (event.type === 'run_failed') {
130
+ job.status = 'failed';
131
+ job.last_error = redactText(String(event.error || 'run failed'));
132
+ job.finished_at = job.finished_at || nowIso();
133
+ job.exit_code = 1;
134
+ }
135
+ }
136
+ if (['running', 'stopping'].includes(String(job.status ?? ''))) {
137
+ const reportStatus = reconcileFromPipelineReport(job);
138
+ if (reportStatus) {
139
+ Object.assign(job, reportStatus);
140
+ }
141
+ else {
142
+ const runDbStatus = reconcileFromRunDb(job);
143
+ if (runDbStatus) {
144
+ Object.assign(job, runDbStatus);
145
+ }
146
+ }
147
+ }
148
+ if (['running', 'stopping'].includes(String(job.status ?? '')) && !processAlive(Number(job.pid ?? 0))) {
149
+ if (job.status === 'stopping') {
150
+ job.status = 'stopped';
151
+ }
152
+ else if (!events.length) {
153
+ job.status = 'failed';
154
+ job.last_error = 'process exited without summary';
155
+ }
156
+ else {
157
+ job.status = 'failed';
158
+ job.last_error = job.last_error || 'process exited without terminal status';
159
+ }
160
+ job.finished_at = job.finished_at || nowIso();
161
+ if (job.exit_code === null || job.exit_code === undefined) {
162
+ job.exit_code = 1;
163
+ }
164
+ }
165
+ writeJob(job);
166
+ return job;
167
+ }
168
+ function loadIndex(projectRoot, env = process.env) {
169
+ const indexPath = path.join(jobsRoot(projectRoot, env), 'index.json');
170
+ if (!fs.existsSync(indexPath)) {
171
+ return { schema_version: 1, latest_job_id: '', jobs: [] };
172
+ }
173
+ return readJson(indexPath);
174
+ }
175
+ export function latestJobId(projectRoot, env = process.env) {
176
+ const jobId = String(loadIndex(projectRoot, env).latest_job_id ?? '');
177
+ if (!jobId) {
178
+ throw new Error('no jobs found');
179
+ }
180
+ return jobId;
181
+ }
182
+ export function activeJobIds(projectRoot, env = process.env) {
183
+ const index = loadIndex(projectRoot, env);
184
+ const active = [];
185
+ for (const item of (index.jobs ?? [])) {
186
+ try {
187
+ const job = loadJob(projectRoot, String(item.job_id ?? ''), env);
188
+ if (['running', 'stopping'].includes(String(job.status ?? '')) && processAlive(Number(job.pid ?? 0))) {
189
+ active.push(String(job.job_id));
190
+ }
191
+ }
192
+ catch {
193
+ // Ignore stale index rows that no longer have job metadata.
194
+ }
195
+ }
196
+ return active;
197
+ }
198
+ export function singleActiveJobId(projectRoot, env = process.env) {
199
+ const active = activeJobIds(projectRoot, env);
200
+ if (active.length > 1) {
201
+ throw new Error(`multiple active jobs: ${active.join(', ')}`);
202
+ }
203
+ if (!active.length) {
204
+ throw new Error('no active jobs');
205
+ }
206
+ return active[0];
207
+ }
208
+ export function loadJob(projectRoot, jobId, env = process.env) {
209
+ const jobPath = path.join(jobsRoot(projectRoot, env), jobId, 'job.json');
210
+ if (!fs.existsSync(jobPath)) {
211
+ throw new Error(`job not found: ${jobId}`);
212
+ }
213
+ return reconcileJob(readJson(jobPath));
214
+ }
215
+ export function publicJobPayload(job) {
216
+ const keys = [
217
+ 'job_id',
218
+ 'kind',
219
+ 'status',
220
+ 'pid',
221
+ 'pgid',
222
+ 'created_at',
223
+ 'started_at',
224
+ 'finished_at',
225
+ 'exit_code',
226
+ 'signal',
227
+ 'project_root',
228
+ 'event_log',
229
+ 'human_log',
230
+ 'stdout_log',
231
+ 'stderr_log',
232
+ 'artifact_dir',
233
+ 'session_dir',
234
+ 'options',
235
+ 'retry',
236
+ 'stop_requested_at',
237
+ 'last_event_at',
238
+ 'last_error',
239
+ 'job_file'
240
+ ];
241
+ const payload = Object.fromEntries(keys.map((key) => [key, job[key]]));
242
+ if (payload.last_error) {
243
+ payload.last_error = redactText(String(payload.last_error));
244
+ }
245
+ return payload;
246
+ }
247
+ export function listJobs(projectRoot, env = process.env) {
248
+ const index = loadIndex(projectRoot, env);
249
+ const jobs = (index.jobs ?? [])
250
+ .map((item) => {
251
+ try {
252
+ const job = loadJob(projectRoot, String(item.job_id ?? ''), env);
253
+ return {
254
+ job_id: job.job_id,
255
+ status: job.status,
256
+ kind: job.kind,
257
+ created_at: job.created_at,
258
+ job_file: job.job_file
259
+ };
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ })
265
+ .filter(Boolean);
266
+ return { ok: true, jobs, latest_job_id: index.latest_job_id ?? '' };
267
+ }
268
+ export function tailLog(projectRoot, jobId, argv, env = process.env) {
269
+ const job = loadJob(projectRoot, jobId, env);
270
+ const logFormat = readOptionValue(argv, '--format') ?? 'human';
271
+ const tail = Number.parseInt(readOptionValue(argv, '--tail') ?? '200', 10);
272
+ const logPath = String(logFormat === 'jsonl' ? job.event_log : job.human_log);
273
+ if (!fs.existsSync(logPath)) {
274
+ return '';
275
+ }
276
+ let lines = fs.readFileSync(logPath, 'utf8').split(/\r?\n/);
277
+ if (lines.at(-1) === '') {
278
+ lines = lines.slice(0, -1);
279
+ }
280
+ const selected = tail > 0 ? lines.slice(-tail) : lines;
281
+ return selected.length ? `${selected.join('\n')}\n` : '';
282
+ }
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { resolveProfilePath } from '../runtime/paths.js';
5
+ function defaultNow() {
6
+ return new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
7
+ }
8
+ function defaultJobId() {
9
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/T/, '-').slice(0, 15);
10
+ return `${stamp}-${randomUUID().replace(/-/g, '').slice(0, 6)}`;
11
+ }
12
+ function readJson(filePath) {
13
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
14
+ }
15
+ function writeJson(filePath, payload) {
16
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
+ const tmpPath = `${filePath}.tmp`;
18
+ fs.writeFileSync(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
19
+ fs.renameSync(tmpPath, filePath);
20
+ }
21
+ export class JobStore {
22
+ projectRoot;
23
+ env;
24
+ now;
25
+ jobId;
26
+ constructor(projectRoot, options = {}) {
27
+ this.projectRoot = path.resolve(projectRoot);
28
+ this.env = options.env ?? process.env;
29
+ this.now = options.now ?? defaultNow;
30
+ this.jobId = options.jobId ?? defaultJobId;
31
+ }
32
+ jobsRoot() {
33
+ return path.join(path.dirname(resolveProfilePath(this.projectRoot, this.env)), 'jobs');
34
+ }
35
+ indexPath() {
36
+ return path.join(this.jobsRoot(), 'index.json');
37
+ }
38
+ loadIndex() {
39
+ if (!fs.existsSync(this.indexPath())) {
40
+ return { schema_version: 1, latest_job_id: '', jobs: [] };
41
+ }
42
+ return readJson(this.indexPath());
43
+ }
44
+ createRunningJob(input) {
45
+ const jobId = this.jobId();
46
+ const jobDir = path.join(this.jobsRoot(), jobId);
47
+ fs.mkdirSync(jobDir, { recursive: true });
48
+ const createdAt = this.now();
49
+ const job = {
50
+ schema_version: 1,
51
+ job_id: jobId,
52
+ kind: input.kind,
53
+ status: 'running',
54
+ pid: input.pid,
55
+ pgid: input.pid,
56
+ created_at: createdAt,
57
+ started_at: createdAt,
58
+ finished_at: '',
59
+ updated_at: createdAt,
60
+ exit_code: null,
61
+ signal: '',
62
+ project_root: this.projectRoot,
63
+ command: input.command,
64
+ event_log: path.join(jobDir, 'events.jsonl'),
65
+ human_log: path.join(jobDir, 'human.log'),
66
+ stdout_log: path.join(jobDir, 'stdout.log'),
67
+ stderr_log: path.join(jobDir, 'stderr.log'),
68
+ artifact_dir: '',
69
+ session_dir: jobDir,
70
+ resume_from: input.resumeFrom ?? '',
71
+ retry: input.retry ?? { source_artifact_dir: '', stage: '' },
72
+ options: input.options,
73
+ stop_requested_at: '',
74
+ last_event_at: '',
75
+ last_error: '',
76
+ job_file: path.join(jobDir, 'job.json')
77
+ };
78
+ for (const key of ['event_log', 'human_log', 'stdout_log', 'stderr_log']) {
79
+ fs.closeSync(fs.openSync(String(job[key]), 'a'));
80
+ }
81
+ return this.writeJob(job);
82
+ }
83
+ loadJob(jobId) {
84
+ const jobPath = path.join(this.jobsRoot(), jobId, 'job.json');
85
+ if (!fs.existsSync(jobPath)) {
86
+ throw new Error(`job not found: ${jobId}`);
87
+ }
88
+ return readJson(jobPath);
89
+ }
90
+ writeJob(job) {
91
+ job.updated_at = this.now();
92
+ writeJson(String(job.job_file), job);
93
+ this.updateIndex(job);
94
+ return job;
95
+ }
96
+ updateIndex(job) {
97
+ const index = this.loadIndex();
98
+ const jobs = (index.jobs ?? []).filter((item) => item.job_id !== job.job_id);
99
+ jobs.push({
100
+ job_id: job.job_id,
101
+ status: job.status,
102
+ kind: job.kind,
103
+ created_at: job.created_at,
104
+ job_file: job.job_file
105
+ });
106
+ writeJson(this.indexPath(), {
107
+ schema_version: 1,
108
+ latest_job_id: job.job_id,
109
+ jobs
110
+ });
111
+ }
112
+ }
113
+ export function createJobStore(projectRoot, options = {}) {
114
+ return new JobStore(projectRoot, options);
115
+ }