ennodia 0.1.0-rc.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/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/bench/README.md +32 -0
- package/bench/bug-recall/run.ts +331 -0
- package/bench/bug-recall/scorer.ts +424 -0
- package/bench/fixtures/bug-recall/001-missing-await/input.ts +33 -0
- package/bench/fixtures/bug-recall/001-missing-await/oracle.json +28 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/input.ts +40 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/oracle.json +28 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/input.ts +22 -0
- package/bench/fixtures/bug-recall/003-schema-contract/oracle.json +28 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/input.ts +28 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/oracle.json +28 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/ennodia-parallel-compare.md +1 -0
- package/bench/results/.gitkeep +1 -0
- package/docs/assets/agents/antigravity-color.svg +1 -0
- package/docs/assets/agents/claudecode-color.svg +1 -0
- package/docs/assets/agents/codex-color.svg +1 -0
- package/docs/assets/agents/opencode-dark.svg +1 -0
- package/docs/assets/agents/opencode-light.svg +1 -0
- package/docs/assets/icons/arrow.svg +1 -0
- package/docs/assets/icons/github.svg +1 -0
- package/docs/assets/icons/link.svg +1 -0
- package/docs/assets/logo-dark.svg +1 -0
- package/docs/assets/logo.svg +1 -0
- package/docs/assets/orchestration-pipeline.svg +74 -0
- package/docs/getting-started.md +107 -0
- package/docs/in-depth/architecture.md +53 -0
- package/docs/in-depth/releasing.md +120 -0
- package/docs/index.md +16 -0
- package/docs/reference/mcp-tools.md +104 -0
- package/package.json +87 -0
- package/src/compare.ts +777 -0
- package/src/diagnosis.ts +161 -0
- package/src/harnesses.ts +302 -0
- package/src/index.ts +52 -0
- package/src/planner.ts +106 -0
- package/src/runs.ts +777 -0
- package/src/server.ts +747 -0
- package/src/tasks.ts +602 -0
- package/src/version.ts +1 -0
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type {
|
|
5
|
+
HarnessAdapter,
|
|
6
|
+
HarnessDiscovery,
|
|
7
|
+
HarnessRunInput,
|
|
8
|
+
} from "./harnesses";
|
|
9
|
+
|
|
10
|
+
export type TaskStatus = "running" | "succeeded" | "failed" | "cancelled";
|
|
11
|
+
|
|
12
|
+
export type TaskEvent = {
|
|
13
|
+
at: string;
|
|
14
|
+
type: "started" | "stdout" | "stderr" | "tick" | "exit" | "cancel" | "error";
|
|
15
|
+
message?: string;
|
|
16
|
+
elapsedMs?: number;
|
|
17
|
+
remainingMs?: number | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TaskView = {
|
|
21
|
+
id: string;
|
|
22
|
+
harnessId: string;
|
|
23
|
+
harnessName: string;
|
|
24
|
+
status: TaskStatus;
|
|
25
|
+
cancelRequested: boolean;
|
|
26
|
+
pid?: number;
|
|
27
|
+
cwd: string;
|
|
28
|
+
command: string[];
|
|
29
|
+
promptPreview: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
endedAt?: string;
|
|
33
|
+
elapsedMs: number;
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
remainingMs: number | null;
|
|
36
|
+
etaConfidence: "timeout-budget" | "unknown" | "complete";
|
|
37
|
+
lastOutputAt?: string;
|
|
38
|
+
exitCode?: number | null;
|
|
39
|
+
timedOut: boolean;
|
|
40
|
+
drainTimedOut: boolean;
|
|
41
|
+
stdoutChars: number;
|
|
42
|
+
stderrChars: number;
|
|
43
|
+
eventCount: number;
|
|
44
|
+
stdout: string;
|
|
45
|
+
stderr: string;
|
|
46
|
+
events: TaskEvent[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type TaskViewOptions = {
|
|
50
|
+
includeOutput?: boolean;
|
|
51
|
+
includeEvents?: boolean;
|
|
52
|
+
maxOutputChars?: number;
|
|
53
|
+
maxEvents?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type InternalTask = Omit<
|
|
57
|
+
TaskView,
|
|
58
|
+
| "createdAt"
|
|
59
|
+
| "updatedAt"
|
|
60
|
+
| "endedAt"
|
|
61
|
+
| "elapsedMs"
|
|
62
|
+
| "remainingMs"
|
|
63
|
+
| "etaConfidence"
|
|
64
|
+
| "lastOutputAt"
|
|
65
|
+
| "stdoutChars"
|
|
66
|
+
| "stderrChars"
|
|
67
|
+
| "eventCount"
|
|
68
|
+
> & {
|
|
69
|
+
createdAtMs: number;
|
|
70
|
+
updatedAtMs: number;
|
|
71
|
+
endedAtMs?: number;
|
|
72
|
+
lastOutputAtMs?: number;
|
|
73
|
+
process?: Bun.Subprocess<"ignore" | "pipe", "pipe", "pipe">;
|
|
74
|
+
streamsDone?: Promise<void>;
|
|
75
|
+
settled?: Promise<void>;
|
|
76
|
+
streamReaders: Set<ReadableStreamDefaultReader<Uint8Array>>;
|
|
77
|
+
tick?: Timer;
|
|
78
|
+
timeout?: Timer;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type StartTaskResult = {
|
|
82
|
+
task: TaskView;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type TaskManagerOptions = {
|
|
86
|
+
drainTimeoutMs?: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type TaskManagerShutdownOptions = {
|
|
90
|
+
deadlineMs?: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
94
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 1_000;
|
|
95
|
+
const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000;
|
|
96
|
+
const MAX_CAPTURE_CHARS = 200_000;
|
|
97
|
+
const MAX_EVENT_MESSAGE_CHARS = 4_000;
|
|
98
|
+
const MAX_EVENTS = 300;
|
|
99
|
+
|
|
100
|
+
export class TaskManager {
|
|
101
|
+
private readonly tasks = new Map<string, InternalTask>();
|
|
102
|
+
private readonly drainTimeoutMs: number;
|
|
103
|
+
private shuttingDown = false;
|
|
104
|
+
private shutdownPromise?: Promise<void>;
|
|
105
|
+
|
|
106
|
+
constructor(options: TaskManagerOptions = {}) {
|
|
107
|
+
this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
start(
|
|
111
|
+
adapter: HarnessAdapter,
|
|
112
|
+
discovery: HarnessDiscovery,
|
|
113
|
+
input: HarnessRunInput,
|
|
114
|
+
): StartTaskResult {
|
|
115
|
+
if (this.shuttingDown) {
|
|
116
|
+
throw new Error("TaskManager is shutting down.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!adapter.buildCommand || !discovery.commandPath || !discovery.runnable) {
|
|
120
|
+
throw new Error(`${adapter.name} is not runnable through Ennodia yet.`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
124
|
+
const commandSpec = adapter.buildCommand(discovery.commandPath, input);
|
|
125
|
+
const cwd = commandSpec.cwd ?? process.cwd();
|
|
126
|
+
|
|
127
|
+
if (!existsSync(cwd)) {
|
|
128
|
+
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
const task: InternalTask = {
|
|
133
|
+
id: randomUUID(),
|
|
134
|
+
harnessId: adapter.id,
|
|
135
|
+
harnessName: adapter.name,
|
|
136
|
+
status: "running",
|
|
137
|
+
cwd,
|
|
138
|
+
command: [
|
|
139
|
+
basename(commandSpec.command),
|
|
140
|
+
...commandSpec.args.map((arg) => arg === input.prompt ? "<prompt>" : arg),
|
|
141
|
+
...(commandSpec.stdin === undefined ? [] : ["<stdin-prompt>"]),
|
|
142
|
+
],
|
|
143
|
+
promptPreview: preview(input.prompt),
|
|
144
|
+
createdAtMs: now,
|
|
145
|
+
updatedAtMs: now,
|
|
146
|
+
timeoutMs,
|
|
147
|
+
cancelRequested: false,
|
|
148
|
+
drainTimedOut: false,
|
|
149
|
+
timedOut: false,
|
|
150
|
+
stdout: "",
|
|
151
|
+
stderr: "",
|
|
152
|
+
events: [],
|
|
153
|
+
streamReaders: new Set(),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
this.tasks.set(task.id, task);
|
|
157
|
+
this.pushEvent(task, { type: "started", message: "Task started." });
|
|
158
|
+
|
|
159
|
+
const child = Bun.spawn({
|
|
160
|
+
cmd: [commandSpec.command, ...commandSpec.args],
|
|
161
|
+
cwd,
|
|
162
|
+
env: { ...process.env, ...commandSpec.env },
|
|
163
|
+
stdin: commandSpec.stdin === undefined ? "ignore" : "pipe",
|
|
164
|
+
stdout: "pipe",
|
|
165
|
+
stderr: "pipe",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
task.process = child;
|
|
169
|
+
task.pid = child.pid;
|
|
170
|
+
|
|
171
|
+
if (commandSpec.stdin !== undefined) {
|
|
172
|
+
const childStdin = child.stdin;
|
|
173
|
+
if (!childStdin) {
|
|
174
|
+
throw new Error("Child stdin pipe was not available.");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
childStdin.write(commandSpec.stdin);
|
|
178
|
+
childStdin.end();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
task.streamsDone = Promise.all([
|
|
182
|
+
this.pipeStreamSafely(task, "stdout", child.stdout),
|
|
183
|
+
this.pipeStreamSafely(task, "stderr", child.stderr),
|
|
184
|
+
]).then(() => undefined);
|
|
185
|
+
|
|
186
|
+
task.tick = setInterval(() => {
|
|
187
|
+
this.touch(task);
|
|
188
|
+
const view = this.toView(task);
|
|
189
|
+
this.pushEvent(task, {
|
|
190
|
+
type: "tick",
|
|
191
|
+
elapsedMs: view.elapsedMs,
|
|
192
|
+
remainingMs: view.remainingMs,
|
|
193
|
+
});
|
|
194
|
+
}, 1_000);
|
|
195
|
+
|
|
196
|
+
task.timeout = setTimeout(() => {
|
|
197
|
+
if (task.cancelRequested) {
|
|
198
|
+
child.kill();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
task.timedOut = true;
|
|
203
|
+
this.pushEvent(task, {
|
|
204
|
+
type: "error",
|
|
205
|
+
message: `Timed out after ${timeoutMs}ms.`,
|
|
206
|
+
});
|
|
207
|
+
child.kill();
|
|
208
|
+
}, timeoutMs);
|
|
209
|
+
|
|
210
|
+
task.settled = this.watchExit(task, child);
|
|
211
|
+
void task.settled;
|
|
212
|
+
|
|
213
|
+
return { task: this.toView(task) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
list(): TaskView[] {
|
|
217
|
+
return this.listViews({
|
|
218
|
+
includeOutput: false,
|
|
219
|
+
includeEvents: false,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
listViews(options: TaskViewOptions = {}): TaskView[] {
|
|
224
|
+
return [...this.tasks.values()]
|
|
225
|
+
.sort((a, b) => b.createdAtMs - a.createdAtMs)
|
|
226
|
+
.map((task) => this.toView(task, options));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
get(taskId: string, options: TaskViewOptions = {}): TaskView | undefined {
|
|
230
|
+
const task = this.tasks.get(taskId);
|
|
231
|
+
return task ? this.toView(task, options) : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
cancel(taskId: string): TaskView {
|
|
235
|
+
const task = this.tasks.get(taskId);
|
|
236
|
+
if (!task) {
|
|
237
|
+
throw new Error(`Unknown task: ${taskId}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (task.status !== "running") {
|
|
241
|
+
return this.toView(task);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.requestCancel(task, "Cancellation requested.");
|
|
245
|
+
return this.toView(task);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async waitForTerminal(
|
|
249
|
+
taskId: string,
|
|
250
|
+
timeoutMs?: number,
|
|
251
|
+
): Promise<TaskView | undefined> {
|
|
252
|
+
const task = this.tasks.get(taskId);
|
|
253
|
+
if (!task) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (task.status === "running" && task.settled) {
|
|
258
|
+
await this.waitForSettled([task], timeoutMs);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return this.toView(task);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async shutdown(options: TaskManagerShutdownOptions = {}): Promise<void> {
|
|
265
|
+
this.shuttingDown = true;
|
|
266
|
+
this.shutdownPromise ??= this.performShutdown(
|
|
267
|
+
options.deadlineMs ?? DEFAULT_SHUTDOWN_DEADLINE_MS,
|
|
268
|
+
);
|
|
269
|
+
await this.shutdownPromise;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async performShutdown(deadlineMs: number): Promise<void> {
|
|
273
|
+
const runningTasks = [...this.tasks.values()].filter((task) =>
|
|
274
|
+
task.status === "running"
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
for (const task of runningTasks) {
|
|
278
|
+
this.requestCancel(task, "Cancellation requested by shutdown.");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await this.waitForSettled(runningTasks, deadlineMs);
|
|
282
|
+
|
|
283
|
+
const stillRunning = runningTasks.filter((task) =>
|
|
284
|
+
task.status === "running"
|
|
285
|
+
);
|
|
286
|
+
for (const task of stillRunning) {
|
|
287
|
+
this.pushEvent(task, {
|
|
288
|
+
type: "error",
|
|
289
|
+
message: `Shutdown deadline exceeded after ${deadlineMs}ms; force-killing task.`,
|
|
290
|
+
});
|
|
291
|
+
task.process?.kill("SIGKILL");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await Promise.allSettled(
|
|
295
|
+
stillRunning.map((task) => task.settled ?? Promise.resolve()),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
for (const task of this.tasks.values()) {
|
|
299
|
+
this.clearTimers(task);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async watchExit(
|
|
304
|
+
task: InternalTask,
|
|
305
|
+
child: Bun.Subprocess<"ignore" | "pipe", "pipe", "pipe">,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
try {
|
|
308
|
+
const exitCode = await child.exited;
|
|
309
|
+
await this.waitForOutputDrain(task);
|
|
310
|
+
task.exitCode = exitCode;
|
|
311
|
+
|
|
312
|
+
if (task.cancelRequested) {
|
|
313
|
+
task.status = "cancelled";
|
|
314
|
+
} else if (task.status === "running") {
|
|
315
|
+
task.status =
|
|
316
|
+
exitCode === 0 && !task.timedOut && !task.drainTimedOut
|
|
317
|
+
? "succeeded"
|
|
318
|
+
: "failed";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
task.endedAtMs = Date.now();
|
|
322
|
+
this.pushEvent(task, {
|
|
323
|
+
type: "exit",
|
|
324
|
+
message: `Process exited with code ${exitCode}.`,
|
|
325
|
+
});
|
|
326
|
+
} catch (error) {
|
|
327
|
+
await this.waitForOutputDrain(task);
|
|
328
|
+
|
|
329
|
+
if (task.cancelRequested) {
|
|
330
|
+
task.status = "cancelled";
|
|
331
|
+
} else if (task.status === "running") {
|
|
332
|
+
task.status = "failed";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
task.endedAtMs = Date.now();
|
|
336
|
+
this.pushEvent(task, {
|
|
337
|
+
type: "error",
|
|
338
|
+
message: error instanceof Error ? error.message : String(error),
|
|
339
|
+
});
|
|
340
|
+
} finally {
|
|
341
|
+
this.clearTimers(task);
|
|
342
|
+
this.touch(task);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private requestCancel(task: InternalTask, message: string): void {
|
|
347
|
+
if (!task.cancelRequested) {
|
|
348
|
+
task.cancelRequested = true;
|
|
349
|
+
this.pushEvent(task, { type: "cancel", message });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
task.process?.kill();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private async waitForSettled(
|
|
356
|
+
tasks: InternalTask[],
|
|
357
|
+
timeoutMs?: number,
|
|
358
|
+
): Promise<void> {
|
|
359
|
+
const waiting = tasks
|
|
360
|
+
.filter((task) => task.status === "running")
|
|
361
|
+
.map((task) => task.settled ?? Promise.resolve());
|
|
362
|
+
|
|
363
|
+
if (waiting.length === 0) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const allSettled = Promise.allSettled(waiting).then(() => undefined);
|
|
368
|
+
if (timeoutMs === undefined) {
|
|
369
|
+
await allSettled;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let timeout: Timer | undefined;
|
|
374
|
+
await Promise.race([
|
|
375
|
+
allSettled,
|
|
376
|
+
new Promise<void>((resolve) => {
|
|
377
|
+
timeout = setTimeout(resolve, timeoutMs);
|
|
378
|
+
}),
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
if (timeout) {
|
|
382
|
+
clearTimeout(timeout);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async waitForOutputDrain(task: InternalTask): Promise<void> {
|
|
387
|
+
if (!task.streamsDone) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let timeout: Timer | undefined;
|
|
392
|
+
const drained = await Promise.race([
|
|
393
|
+
task.streamsDone.then(() => true),
|
|
394
|
+
new Promise<boolean>((resolve) => {
|
|
395
|
+
timeout = setTimeout(() => resolve(false), this.drainTimeoutMs);
|
|
396
|
+
}),
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
if (timeout) {
|
|
400
|
+
clearTimeout(timeout);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (drained) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
task.drainTimedOut = true;
|
|
408
|
+
this.pushEvent(task, {
|
|
409
|
+
type: "error",
|
|
410
|
+
message: `Output drain timed out after ${this.drainTimeoutMs}ms; captured output may be incomplete.`,
|
|
411
|
+
});
|
|
412
|
+
this.cancelStreamReaders(task);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private cancelStreamReaders(task: InternalTask): void {
|
|
416
|
+
for (const reader of task.streamReaders) {
|
|
417
|
+
void reader.cancel("Output drain timed out.").catch((error) => {
|
|
418
|
+
this.pushEvent(task, {
|
|
419
|
+
type: "error",
|
|
420
|
+
message: `Output reader cancellation failed: ${
|
|
421
|
+
error instanceof Error ? error.message : String(error)
|
|
422
|
+
}`,
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private async pipeStreamSafely(
|
|
429
|
+
task: InternalTask,
|
|
430
|
+
streamName: "stdout" | "stderr",
|
|
431
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
432
|
+
): Promise<void> {
|
|
433
|
+
try {
|
|
434
|
+
await this.pipeStream(task, streamName, stream);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
this.pushEvent(task, {
|
|
437
|
+
type: "error",
|
|
438
|
+
message: `${streamName} read failed: ${
|
|
439
|
+
error instanceof Error ? error.message : String(error)
|
|
440
|
+
}`,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private async pipeStream(
|
|
446
|
+
task: InternalTask,
|
|
447
|
+
streamName: "stdout" | "stderr",
|
|
448
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
449
|
+
): Promise<void> {
|
|
450
|
+
if (!stream) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const decoder = new TextDecoder();
|
|
455
|
+
const reader = stream.getReader();
|
|
456
|
+
task.streamReaders.add(reader);
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
while (true) {
|
|
460
|
+
const { done, value } = await reader.read();
|
|
461
|
+
if (done) {
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
466
|
+
task[streamName] = appendLimited(task[streamName], chunk);
|
|
467
|
+
task.lastOutputAtMs = Date.now();
|
|
468
|
+
this.pushEvent(task, { type: streamName, message: chunk });
|
|
469
|
+
this.touch(task);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const finalChunk = decoder.decode();
|
|
473
|
+
if (finalChunk) {
|
|
474
|
+
task[streamName] = appendLimited(task[streamName], finalChunk);
|
|
475
|
+
task.lastOutputAtMs = Date.now();
|
|
476
|
+
this.pushEvent(task, { type: streamName, message: finalChunk });
|
|
477
|
+
this.touch(task);
|
|
478
|
+
}
|
|
479
|
+
} finally {
|
|
480
|
+
task.streamReaders.delete(reader);
|
|
481
|
+
reader.releaseLock();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private toView(task: InternalTask, options: TaskViewOptions = {}): TaskView {
|
|
486
|
+
const now = Date.now();
|
|
487
|
+
const end = task.endedAtMs ?? now;
|
|
488
|
+
const elapsedMs = Math.max(0, end - task.createdAtMs);
|
|
489
|
+
const running = task.status === "running";
|
|
490
|
+
const remainingMs = running
|
|
491
|
+
? Math.max(0, task.timeoutMs - elapsedMs)
|
|
492
|
+
: 0;
|
|
493
|
+
const includeOutput = options.includeOutput ?? true;
|
|
494
|
+
const includeEvents = options.includeEvents ?? true;
|
|
495
|
+
const maxOutputChars = options.maxOutputChars ?? MAX_CAPTURE_CHARS;
|
|
496
|
+
const maxEvents = options.maxEvents ?? MAX_EVENTS;
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
id: task.id,
|
|
500
|
+
harnessId: task.harnessId,
|
|
501
|
+
harnessName: task.harnessName,
|
|
502
|
+
status: task.status,
|
|
503
|
+
cancelRequested: task.cancelRequested,
|
|
504
|
+
pid: task.pid,
|
|
505
|
+
cwd: task.cwd,
|
|
506
|
+
command: task.command,
|
|
507
|
+
promptPreview: task.promptPreview,
|
|
508
|
+
createdAt: new Date(task.createdAtMs).toISOString(),
|
|
509
|
+
updatedAt: new Date(task.updatedAtMs).toISOString(),
|
|
510
|
+
endedAt: task.endedAtMs ? new Date(task.endedAtMs).toISOString() : undefined,
|
|
511
|
+
elapsedMs,
|
|
512
|
+
timeoutMs: task.timeoutMs,
|
|
513
|
+
remainingMs,
|
|
514
|
+
etaConfidence: running ? "timeout-budget" : "complete",
|
|
515
|
+
lastOutputAt: task.lastOutputAtMs
|
|
516
|
+
? new Date(task.lastOutputAtMs).toISOString()
|
|
517
|
+
: undefined,
|
|
518
|
+
exitCode: task.exitCode,
|
|
519
|
+
timedOut: task.timedOut,
|
|
520
|
+
drainTimedOut: task.drainTimedOut,
|
|
521
|
+
stdoutChars: task.stdout.length,
|
|
522
|
+
stderrChars: task.stderr.length,
|
|
523
|
+
eventCount: task.events.length,
|
|
524
|
+
stdout: includeOutput ? tail(task.stdout, maxOutputChars) : "",
|
|
525
|
+
stderr: includeOutput ? tail(task.stderr, maxOutputChars) : "",
|
|
526
|
+
events: includeEvents ? tailItems(task.events, maxEvents) : [],
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private pushEvent(
|
|
531
|
+
task: InternalTask,
|
|
532
|
+
event: Omit<TaskEvent, "at">,
|
|
533
|
+
): void {
|
|
534
|
+
task.events.push({
|
|
535
|
+
at: new Date().toISOString(),
|
|
536
|
+
...event,
|
|
537
|
+
message: event.message ? trimEventMessage(event.message) : undefined,
|
|
538
|
+
});
|
|
539
|
+
if (task.events.length > MAX_EVENTS) {
|
|
540
|
+
task.events.splice(0, task.events.length - MAX_EVENTS);
|
|
541
|
+
}
|
|
542
|
+
this.touch(task);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private touch(task: InternalTask): void {
|
|
546
|
+
task.updatedAtMs = Date.now();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private clearTimers(task: InternalTask): void {
|
|
550
|
+
if (task.tick) {
|
|
551
|
+
clearInterval(task.tick);
|
|
552
|
+
task.tick = undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (task.timeout) {
|
|
556
|
+
clearTimeout(task.timeout);
|
|
557
|
+
task.timeout = undefined;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function appendLimited(current: string, chunk: string): string {
|
|
563
|
+
const next = current + chunk;
|
|
564
|
+
if (next.length <= MAX_CAPTURE_CHARS) {
|
|
565
|
+
return next;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return next.slice(next.length - MAX_CAPTURE_CHARS);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function tail(text: string, maxChars: number): string {
|
|
572
|
+
if (maxChars <= 0) {
|
|
573
|
+
return "";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (text.length <= maxChars) {
|
|
577
|
+
return text;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return text.slice(text.length - maxChars);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function preview(text: string): string {
|
|
584
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
585
|
+
return clean.length <= 160 ? clean : `${clean.slice(0, 157)}...`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function trimEventMessage(message: string): string {
|
|
589
|
+
if (message.length <= MAX_EVENT_MESSAGE_CHARS) {
|
|
590
|
+
return message;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return `${message.slice(0, MAX_EVENT_MESSAGE_CHARS)}...`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function tailItems<T>(items: T[], maxItems: number): T[] {
|
|
597
|
+
if (maxItems <= 0) {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return items.slice(-maxItems);
|
|
602
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ENNODIA_VERSION = "0.1.0-rc.0";
|