ds4-context-engine 0.3.3 → 0.3.5

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.
@@ -0,0 +1,176 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, openSync, readSync, unlinkSync, writeSync } from "node:fs";
3
+ import { chmod, mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { BashOperations } from "@earendil-works/pi-coding-agent";
7
+
8
+ export const MAX_RUNNING_JOBS = 4;
9
+ export const MAX_RETAINED_JOBS = 16;
10
+ export const MAX_JOB_OUTPUT_BYTES = 8 * 1024 * 1024;
11
+ export const DEFAULT_JOB_TIMEOUT_SECONDS = 300;
12
+ export const MAX_JOB_TIMEOUT_SECONDS = 3600;
13
+
14
+ export interface JobOwner { sessionId: string; entryId: string | null }
15
+ export interface JobScope { sessionId: string; branchIds: ReadonlySet<string>; leafId: string | null }
16
+ export type JobStatus = "running" | "exited" | "failed" | "stopped" | "timed-out" | "output-limit" | "log-error";
17
+ export interface JobSnapshot {
18
+ id: string;
19
+ status: JobStatus;
20
+ startedAt: number;
21
+ endedAt?: number;
22
+ exitCode?: number | null;
23
+ outputBytes: number;
24
+ outputPath: string;
25
+ head?: string;
26
+ tail?: string;
27
+ outputTruncated: boolean;
28
+ }
29
+ interface Job {
30
+ owner: JobOwner;
31
+ snapshot: JobSnapshot;
32
+ controller: AbortController;
33
+ stopReason?: JobStatus;
34
+ done: Promise<void>;
35
+ }
36
+
37
+ /** Process-local registry. Pi owns platform-specific process-tree cancellation. */
38
+ export class BashJobManager {
39
+ private jobs = new Map<string, Job>();
40
+ private directory?: Promise<string>;
41
+ private closed = false;
42
+
43
+ constructor(private readonly operations: BashOperations, private readonly onFinish: () => void = () => {}) {}
44
+
45
+ private visible(job: Job, scope: JobScope): boolean {
46
+ return job.owner.sessionId === scope.sessionId
47
+ && (job.owner.entryId === null ? scope.leafId === null : scope.branchIds.has(job.owner.entryId));
48
+ }
49
+
50
+ private find(id: string, scope: JobScope): Job {
51
+ const job = this.jobs.get(id);
52
+ if (!job || !this.visible(job, scope)) throw new Error("Job unavailable in this session/branch");
53
+ return job;
54
+ }
55
+
56
+ async start(command: string, cwd: string, owner: JobOwner, timeout: number, signal?: AbortSignal): Promise<JobSnapshot> {
57
+ if (!command.trim() || command.length > 32_768) throw new Error("Job command must contain 1-32768 characters");
58
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > MAX_JOB_TIMEOUT_SECONDS) throw new Error("Invalid job timeout");
59
+ if (this.closed || signal?.aborted) throw new Error("Job start aborted");
60
+ this.directory ??= (async () => {
61
+ const directory = await mkdtemp(join(tmpdir(), "ds4-bash-jobs-"));
62
+ try { await chmod(directory, 0o700); return directory; }
63
+ catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
64
+ })();
65
+ const directory = await this.directory;
66
+ if (this.closed || signal?.aborted) throw new Error("Job start aborted");
67
+ if ([...this.jobs.values()].filter((job) => job.snapshot.status === "running").length >= MAX_RUNNING_JOBS) {
68
+ throw new Error(`At most ${MAX_RUNNING_JOBS} local jobs may run concurrently`);
69
+ }
70
+ if (this.jobs.size >= MAX_RETAINED_JOBS) {
71
+ const oldest = [...this.jobs.values()].find((job) => job.snapshot.status !== "running");
72
+ if (!oldest) throw new Error("Job registry is full");
73
+ unlinkSync(oldest.snapshot.outputPath);
74
+ this.jobs.delete(oldest.snapshot.id);
75
+ }
76
+ const id = randomUUID();
77
+ const outputPath = join(directory, `${id}.log`);
78
+ const fd = openSync(outputPath, "wx", 0o600);
79
+ const job: Job = {
80
+ owner: { ...owner }, controller: new AbortController(), done: Promise.resolve(),
81
+ snapshot: { id, status: "running", startedAt: Date.now(), outputBytes: 0, outputPath, outputTruncated: false },
82
+ };
83
+ this.jobs.set(id, job);
84
+ const abort = (reason: JobStatus) => {
85
+ job.stopReason ??= reason;
86
+ job.controller.abort();
87
+ };
88
+ job.done = Promise.resolve().then(async () => {
89
+ try {
90
+ const result = await this.operations.exec(command, cwd, {
91
+ timeout,
92
+ signal: job.controller.signal,
93
+ onData: (data) => {
94
+ if (job.stopReason || job.snapshot.status !== "running") return;
95
+ const remaining = MAX_JOB_OUTPUT_BYTES - job.snapshot.outputBytes;
96
+ const size = Math.min(remaining, data.length);
97
+ try {
98
+ let offset = 0;
99
+ while (offset < size) {
100
+ const written = writeSync(fd, data, offset, size - offset);
101
+ if (written === 0) throw new Error("Zero-byte log write");
102
+ offset += written;
103
+ job.snapshot.outputBytes += written;
104
+ }
105
+ if (data.length >= remaining) {
106
+ job.snapshot.outputTruncated = true;
107
+ abort("output-limit");
108
+ }
109
+ } catch { abort("log-error"); }
110
+ },
111
+ });
112
+ job.snapshot.exitCode = result.exitCode;
113
+ job.snapshot.status = job.stopReason ?? "exited";
114
+ } catch (error) {
115
+ job.snapshot.status = job.stopReason
116
+ ?? (error instanceof Error && error.message.startsWith("timeout:") ? "timed-out" : "failed");
117
+ } finally {
118
+ try { closeSync(fd); } catch { job.snapshot.status = "log-error"; }
119
+ job.snapshot.endedAt = Date.now();
120
+ try { this.onFinish(); } catch { /* Completion must not reject an unobserved promise. */ }
121
+ }
122
+ });
123
+ return { ...job.snapshot };
124
+ }
125
+
126
+ list(scope: JobScope): JobSnapshot[] {
127
+ return [...this.jobs.values()].filter((job) => this.visible(job, scope)).map((job) => ({ ...job.snapshot }));
128
+ }
129
+
130
+ status(id: string, scope: JobScope): JobSnapshot {
131
+ const job = this.find(id, scope);
132
+ const snapshot = { ...job.snapshot };
133
+ const fd = openSync(snapshot.outputPath, "r");
134
+ const read = (offset: number, size: number) => {
135
+ const buffer = Buffer.alloc(size);
136
+ return buffer.subarray(0, readSync(fd, buffer, 0, size, offset)).toString("utf8");
137
+ };
138
+ try {
139
+ const headBytes = Math.min(512, snapshot.outputBytes);
140
+ const tailStart = Math.max(headBytes, snapshot.outputBytes - 512);
141
+ snapshot.head = read(0, headBytes);
142
+ if (tailStart < snapshot.outputBytes) snapshot.tail = read(tailStart, snapshot.outputBytes - tailStart);
143
+ snapshot.outputTruncated ||= tailStart > headBytes;
144
+ } finally { closeSync(fd); }
145
+ return snapshot;
146
+ }
147
+
148
+ async stop(id: string, scope: JobScope): Promise<JobSnapshot> {
149
+ const job = this.find(id, scope);
150
+ if (job.snapshot.status === "running") {
151
+ job.stopReason ??= "stopped";
152
+ job.controller.abort();
153
+ await job.done;
154
+ }
155
+ return this.status(id, scope);
156
+ }
157
+
158
+ async stopInvisible(scope: JobScope): Promise<void> {
159
+ const invisible = [...this.jobs.values()].filter((job) => !this.visible(job, scope));
160
+ for (const job of invisible) {
161
+ if (job.snapshot.status === "running") { job.stopReason ??= "stopped"; job.controller.abort(); }
162
+ }
163
+ await Promise.all(invisible.map((job) => job.done));
164
+ }
165
+
166
+ async dispose(): Promise<void> {
167
+ this.closed = true;
168
+ for (const job of this.jobs.values()) {
169
+ if (job.snapshot.status === "running") { job.stopReason ??= "stopped"; job.controller.abort(); }
170
+ }
171
+ await Promise.all([...this.jobs.values()].map((job) => job.done));
172
+ this.jobs.clear();
173
+ const directory = await this.directory?.catch(() => undefined);
174
+ if (directory) await rm(directory, { recursive: true, force: true });
175
+ }
176
+ }