ennodia 0.1.1 → 0.2.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 +60 -36
- package/README.md +74 -58
- package/bench/README.md +7 -5
- package/bench/bug-recall/run.ts +12 -4
- package/bin/ennodia +23 -2
- package/docs/assets/agents/mcp.svg +1 -1
- package/docs/assets/orchestration-pipeline.svg +4 -3
- package/docs/comparisons/agent-frameworks.md +15 -10
- package/docs/comparisons/autogen.md +20 -15
- package/docs/comparisons/chathub.md +15 -14
- package/docs/comparisons/index.md +30 -22
- package/docs/comparisons/langgraph.md +19 -12
- package/docs/comparisons/mixture-of-agents.md +20 -17
- package/docs/comparisons/model-merging.md +7 -5
- package/docs/comparisons/openrouter.md +30 -22
- package/docs/concepts/compositional-audits.md +16 -13
- package/docs/concepts/data-governance.md +31 -15
- package/docs/concepts/how-ennodia-works.md +48 -22
- package/docs/concepts/interfaces-and-core.md +19 -6
- package/docs/concepts/second-opinions.md +20 -20
- package/docs/getting-started.md +55 -27
- package/docs/guides/agent-skills.md +16 -13
- package/docs/guides/budgets-and-limits.md +30 -20
- package/docs/guides/running-better-audits.md +50 -44
- package/docs/index.md +60 -37
- package/docs/install.md +41 -32
- package/docs/reference/benchmarks.md +6 -4
- package/docs/reference/controlled-english.md +130 -0
- package/docs/reference/ennodia-io.md +28 -22
- package/docs/reference/mcp-tools.md +244 -87
- package/docs/reference/supported-harnesses.md +9 -9
- package/package.json +7 -5
- package/src/advisor.ts +685 -0
- package/src/budget.ts +25 -6
- package/src/cli.ts +1 -1
- package/src/compare.ts +248 -52
- package/src/compositional.ts +18 -0
- package/src/core.ts +547 -16
- package/src/harnesses.ts +33 -2
- package/src/index.ts +38 -0
- package/src/plan-advice.ts +601 -0
- package/src/runs.ts +74 -9
- package/src/server.ts +312 -46
- package/src/skills.ts +131 -11
- package/src/tasks.ts +192 -22
- package/src/version.ts +1 -1
package/src/skills.ts
CHANGED
|
@@ -426,6 +426,7 @@ function legacySkillDirectory(path: string, scope: "project" | "user"): SkillDir
|
|
|
426
426
|
async function loadAvailableSkills(cwd?: string): Promise<LoadedSkillDiscovery> {
|
|
427
427
|
const directories = getSkillDirectories(cwd);
|
|
428
428
|
const skillsMap = new Map<string, Skill>();
|
|
429
|
+
const ambiguousHarnessIds = new Map<string, Set<string>>();
|
|
429
430
|
const warnings: string[] = [];
|
|
430
431
|
|
|
431
432
|
for (const directory of directories) {
|
|
@@ -439,10 +440,42 @@ async function loadAvailableSkills(cwd?: string): Promise<LoadedSkillDiscovery>
|
|
|
439
440
|
);
|
|
440
441
|
const withInstallation = addInstallation(skill, directory);
|
|
441
442
|
const existing = skillsMap.get(withInstallation.id);
|
|
442
|
-
|
|
443
|
-
withInstallation.id,
|
|
444
|
-
|
|
445
|
-
|
|
443
|
+
if (!existing) {
|
|
444
|
+
skillsMap.set(withInstallation.id, withInstallation);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (existing.hash !== withInstallation.hash) {
|
|
449
|
+
const ambiguous = ambiguousHarnessIds.get(withInstallation.id) ??
|
|
450
|
+
new Set<string>();
|
|
451
|
+
for (const harnessId of withInstallation.harnessIds) {
|
|
452
|
+
if (existing.harnessIds.includes(harnessId)) {
|
|
453
|
+
ambiguous.add(harnessId);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
ambiguousHarnessIds.set(withInstallation.id, ambiguous);
|
|
457
|
+
skillsMap.set(withInstallation.id, {
|
|
458
|
+
...existing,
|
|
459
|
+
harnessIds: existing.harnessIds.filter((harnessId) =>
|
|
460
|
+
!ambiguous.has(harnessId)
|
|
461
|
+
),
|
|
462
|
+
});
|
|
463
|
+
warnings.push(
|
|
464
|
+
`${filePath}: Conflicting skill content for ${existing.id}: ${existing.path} and ${withInstallation.path}. Ennodia kept the higher-priority installation and removed ambiguous harness support.`,
|
|
465
|
+
);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const merged = mergeSkills(existing, withInstallation);
|
|
470
|
+
const ambiguous = ambiguousHarnessIds.get(withInstallation.id);
|
|
471
|
+
skillsMap.set(withInstallation.id, ambiguous
|
|
472
|
+
? {
|
|
473
|
+
...merged,
|
|
474
|
+
harnessIds: merged.harnessIds.filter((harnessId) =>
|
|
475
|
+
!ambiguous.has(harnessId)
|
|
476
|
+
),
|
|
477
|
+
}
|
|
478
|
+
: merged);
|
|
446
479
|
} catch (error) {
|
|
447
480
|
warnings.push(`${filePath}: ${errorMessage(error)}`);
|
|
448
481
|
}
|
|
@@ -589,6 +622,12 @@ function addInstallation(skill: Skill, directory: SkillDirectory): Skill {
|
|
|
589
622
|
}
|
|
590
623
|
|
|
591
624
|
function mergeSkills(primary: Skill, secondary: Skill): Skill {
|
|
625
|
+
if (primary.hash !== secondary.hash) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`Conflicting skill content for ${primary.id}: ${primary.path} and ${secondary.path}. Ennodia will keep the higher-priority installation without claiming support from the conflicting installation.`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
592
631
|
return {
|
|
593
632
|
...primary,
|
|
594
633
|
harnessIds: [...new Set([...primary.harnessIds, ...secondary.harnessIds])].sort(),
|
|
@@ -721,18 +760,97 @@ function splitFrontmatter(content: string): {
|
|
|
721
760
|
|
|
722
761
|
const metadata: Record<string, string> = {};
|
|
723
762
|
const frontmatter = normalized.slice(4, end).trim();
|
|
724
|
-
|
|
725
|
-
|
|
763
|
+
const lines = frontmatter.split("\n");
|
|
764
|
+
|
|
765
|
+
for (let i = 0; i < lines.length; i++) {
|
|
766
|
+
const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[i].trim());
|
|
726
767
|
if (!match) {
|
|
727
768
|
continue;
|
|
728
769
|
}
|
|
729
770
|
|
|
730
|
-
|
|
771
|
+
const key = match[1];
|
|
772
|
+
const rest = match[2].trim();
|
|
773
|
+
const blockScalar = /^([|>])([+-]?)$/.exec(rest);
|
|
774
|
+
|
|
775
|
+
if (blockScalar) {
|
|
776
|
+
const style = blockScalar[1] as "|" | ">";
|
|
777
|
+
const chomp = blockScalar[2];
|
|
778
|
+
const consumed = readYamlBlockScalar(lines, i + 1);
|
|
779
|
+
metadata[key] = foldYamlBlockScalar(consumed.value, style, chomp);
|
|
780
|
+
i = consumed.nextIndex - 1;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
metadata[key] = unquote(rest);
|
|
731
785
|
}
|
|
732
786
|
|
|
733
787
|
return { metadata, body: normalized.slice(end + 5) };
|
|
734
788
|
}
|
|
735
789
|
|
|
790
|
+
function readYamlBlockScalar(
|
|
791
|
+
lines: string[],
|
|
792
|
+
startIndex: number,
|
|
793
|
+
): { value: string[]; nextIndex: number } {
|
|
794
|
+
const value: string[] = [];
|
|
795
|
+
let indent: number | null = null;
|
|
796
|
+
let i = startIndex;
|
|
797
|
+
|
|
798
|
+
for (; i < lines.length; i++) {
|
|
799
|
+
const rawLine = lines[i];
|
|
800
|
+
if (rawLine.trim() === "") {
|
|
801
|
+
value.push("");
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const lineIndent = rawLine.match(/^ */)?.[0].length ?? 0;
|
|
806
|
+
if (indent === null) {
|
|
807
|
+
if (lineIndent === 0) {
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
indent = lineIndent;
|
|
811
|
+
} else if (lineIndent < indent) {
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
value.push(rawLine.slice(indent ?? 0));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return { value, nextIndex: i };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function foldYamlBlockScalar(
|
|
822
|
+
lines: string[],
|
|
823
|
+
style: "|" | ">",
|
|
824
|
+
chomp: string,
|
|
825
|
+
): string {
|
|
826
|
+
const trimmed = [...lines];
|
|
827
|
+
while (trimmed.length && trimmed[trimmed.length - 1] === "" && chomp !== "+") {
|
|
828
|
+
trimmed.pop();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (style === "|") {
|
|
832
|
+
return trimmed.join("\n");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const paragraphs: string[] = [];
|
|
836
|
+
let current: string[] = [];
|
|
837
|
+
for (const line of trimmed) {
|
|
838
|
+
if (line === "") {
|
|
839
|
+
if (current.length) {
|
|
840
|
+
paragraphs.push(current.join(" "));
|
|
841
|
+
current = [];
|
|
842
|
+
}
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
current.push(line);
|
|
846
|
+
}
|
|
847
|
+
if (current.length) {
|
|
848
|
+
paragraphs.push(current.join(" "));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return paragraphs.join("\n").trim();
|
|
852
|
+
}
|
|
853
|
+
|
|
736
854
|
function stringField(
|
|
737
855
|
record: Record<string, unknown>,
|
|
738
856
|
key: string,
|
|
@@ -805,10 +923,12 @@ function firstParagraph(content: string): string | undefined {
|
|
|
805
923
|
}
|
|
806
924
|
|
|
807
925
|
function skillSupportsHarness(skill: Skill, harnessId: string): boolean {
|
|
808
|
-
|
|
809
|
-
skill.harnessIds.includes(harnessId)
|
|
810
|
-
|
|
811
|
-
|
|
926
|
+
if (skill.native) {
|
|
927
|
+
return skill.harnessIds.includes(harnessId);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return skill.harnessIds.includes(harnessId) ||
|
|
931
|
+
skill.harnessIds.includes("legacy");
|
|
812
932
|
}
|
|
813
933
|
|
|
814
934
|
function unquote(value: string): string {
|
package/src/tasks.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
3
5
|
import { randomUUID } from "node:crypto";
|
|
4
6
|
import type {
|
|
7
|
+
CommandSpec,
|
|
5
8
|
HarnessAdapter,
|
|
6
9
|
HarnessDiscovery,
|
|
7
10
|
HarnessRunInput,
|
|
11
|
+
HarnessUsage,
|
|
8
12
|
} from "./harnesses";
|
|
9
13
|
import { preview, tailItems } from "./internal";
|
|
10
14
|
import {
|
|
@@ -49,6 +53,16 @@ export type TaskView = {
|
|
|
49
53
|
stderr: string;
|
|
50
54
|
events: TaskEvent[];
|
|
51
55
|
appliedSkills?: AppliedSkillInfo[];
|
|
56
|
+
/** Clean final message, distinct from raw stdout, when the adapter supports
|
|
57
|
+
* writing one out-of-band (e.g. Codex's --output-last-message). Prefer this
|
|
58
|
+
* over stdout for cross-harness comparisons: some adapters' stdout is a
|
|
59
|
+
* full session transcript, others' is just the final reply. */
|
|
60
|
+
finalMessage?: string;
|
|
61
|
+
/** Best-effort usage metrics parsed from the adapter's own output. */
|
|
62
|
+
usage?: HarnessUsage;
|
|
63
|
+
/** Set when this task ran against an isolated copy of the requested cwd
|
|
64
|
+
* (see HarnessRunInput.isolateCwd), naming the original directory. */
|
|
65
|
+
isolatedFrom?: string;
|
|
52
66
|
};
|
|
53
67
|
|
|
54
68
|
export type TaskViewOptions = {
|
|
@@ -91,6 +105,11 @@ type InternalTask = Omit<
|
|
|
91
105
|
settled?: Promise<void>;
|
|
92
106
|
streamReaders: Set<ReadableStreamDefaultReader<Uint8Array>>;
|
|
93
107
|
timeout?: Timer;
|
|
108
|
+
finalMessagePath?: string;
|
|
109
|
+
/** Exact temporary directory allocated by TaskManager. Keep this separate
|
|
110
|
+
* from cwd because an adapter can override the command working directory. */
|
|
111
|
+
isolatedCwd?: string;
|
|
112
|
+
extractUsage?: HarnessAdapter["extractUsage"];
|
|
94
113
|
};
|
|
95
114
|
|
|
96
115
|
export type StartTaskResult = {
|
|
@@ -101,6 +120,7 @@ export type TaskManagerOptions = {
|
|
|
101
120
|
drainTimeoutMs?: number;
|
|
102
121
|
maxTasks?: number;
|
|
103
122
|
spawn?: TaskSpawn;
|
|
123
|
+
removeIsolatedCwd?: (path: string) => void;
|
|
104
124
|
};
|
|
105
125
|
|
|
106
126
|
export type TaskManagerShutdownOptions = {
|
|
@@ -120,6 +140,7 @@ export class TaskManager {
|
|
|
120
140
|
private readonly drainTimeoutMs: number;
|
|
121
141
|
private readonly maxTasks: number;
|
|
122
142
|
private readonly spawn: TaskSpawn;
|
|
143
|
+
private readonly removeIsolatedCwd: (path: string) => void;
|
|
123
144
|
private shuttingDown = false;
|
|
124
145
|
private shutdownPromise?: Promise<void>;
|
|
125
146
|
|
|
@@ -127,6 +148,9 @@ export class TaskManager {
|
|
|
127
148
|
this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
128
149
|
this.maxTasks = Math.max(1, options.maxTasks ?? DEFAULT_MAX_TASKS);
|
|
129
150
|
this.spawn = options.spawn ?? ((input) => Bun.spawn(input));
|
|
151
|
+
this.removeIsolatedCwd = options.removeIsolatedCwd ?? ((path) => {
|
|
152
|
+
rmSync(path, { recursive: true, force: true });
|
|
153
|
+
});
|
|
130
154
|
}
|
|
131
155
|
|
|
132
156
|
start(
|
|
@@ -142,18 +166,61 @@ export class TaskManager {
|
|
|
142
166
|
throw new Error(`${adapter.name} is not runnable through Ennodia yet.`);
|
|
143
167
|
}
|
|
144
168
|
|
|
169
|
+
const sourceCwd = input.cwd ?? process.cwd();
|
|
170
|
+
if (!existsSync(sourceCwd)) {
|
|
171
|
+
throw new Error(`Working directory does not exist: ${sourceCwd}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let isolatedCwd: string | undefined;
|
|
175
|
+
let isolatedFrom: string | undefined;
|
|
176
|
+
let resolvedInputCwd = input.cwd;
|
|
177
|
+
if (input.isolateCwd) {
|
|
178
|
+
isolatedCwd = isolateDirectory(sourceCwd);
|
|
179
|
+
resolvedInputCwd = isolatedCwd;
|
|
180
|
+
isolatedFrom = sourceCwd;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Always allocate a scratch path; adapters that support writing a clean
|
|
184
|
+
// final message (e.g. Codex's --output-last-message) opt in by
|
|
185
|
+
// referencing input.finalMessagePath in buildCommand. Adapters that
|
|
186
|
+
// don't support it simply never create the file, and it's cleaned up
|
|
187
|
+
// as a no-op.
|
|
188
|
+
const finalMessagePath = join(tmpdir(), `ennodia-final-${randomUUID()}.txt`);
|
|
189
|
+
|
|
145
190
|
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
146
191
|
const augmentedPrompt = input.skills && input.skills.length > 0
|
|
147
192
|
? augmentPrompt(input.prompt, input.skills, adapter.id)
|
|
148
193
|
: input.prompt;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
194
|
+
let commandSpec: CommandSpec;
|
|
195
|
+
let cwd: string;
|
|
196
|
+
let child: TaskProcess;
|
|
197
|
+
try {
|
|
198
|
+
commandSpec = adapter.buildCommand(discovery.commandPath, {
|
|
199
|
+
...input,
|
|
200
|
+
cwd: resolvedInputCwd,
|
|
201
|
+
prompt: augmentedPrompt,
|
|
202
|
+
finalMessagePath,
|
|
203
|
+
});
|
|
204
|
+
cwd = commandSpec.cwd ?? process.cwd();
|
|
154
205
|
|
|
155
|
-
|
|
156
|
-
|
|
206
|
+
if (!existsSync(cwd)) {
|
|
207
|
+
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
child = this.spawn({
|
|
211
|
+
cmd: [commandSpec.command, ...commandSpec.args],
|
|
212
|
+
cwd,
|
|
213
|
+
env: { ...process.env, ...commandSpec.env },
|
|
214
|
+
stdin: commandSpec.stdin === undefined ? "ignore" : "pipe",
|
|
215
|
+
stdout: "pipe",
|
|
216
|
+
stderr: "pipe",
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
cleanupFailedStartArtifacts(
|
|
220
|
+
finalMessagePath,
|
|
221
|
+
isolatedCwd,
|
|
222
|
+
);
|
|
223
|
+
throw error;
|
|
157
224
|
}
|
|
158
225
|
|
|
159
226
|
const now = Date.now();
|
|
@@ -163,6 +230,7 @@ export class TaskManager {
|
|
|
163
230
|
harnessName: adapter.name,
|
|
164
231
|
status: "running",
|
|
165
232
|
cwd,
|
|
233
|
+
isolatedFrom,
|
|
166
234
|
command: [
|
|
167
235
|
basename(commandSpec.command),
|
|
168
236
|
...commandSpec.args.map((arg) => arg === augmentedPrompt ? "<prompt>" : arg),
|
|
@@ -180,24 +248,20 @@ export class TaskManager {
|
|
|
180
248
|
events: [],
|
|
181
249
|
streamReaders: new Set(),
|
|
182
250
|
appliedSkills: input.skills?.map(toAppliedSkillInfo),
|
|
251
|
+
finalMessagePath,
|
|
252
|
+
isolatedCwd,
|
|
253
|
+
extractUsage: adapter.extractUsage,
|
|
183
254
|
};
|
|
184
255
|
|
|
256
|
+
task.process = child;
|
|
257
|
+
task.pid = child.pid;
|
|
258
|
+
|
|
259
|
+
// Publish a task only after the process exists. A synchronous spawn
|
|
260
|
+
// failure must not leave a phantom running task that cannot settle.
|
|
185
261
|
this.tasks.set(task.id, task);
|
|
186
262
|
this.pruneTasks(task.id);
|
|
187
263
|
this.pushEvent(task, { type: "started", message: "Task started." });
|
|
188
264
|
|
|
189
|
-
const child = this.spawn({
|
|
190
|
-
cmd: [commandSpec.command, ...commandSpec.args],
|
|
191
|
-
cwd,
|
|
192
|
-
env: { ...process.env, ...commandSpec.env },
|
|
193
|
-
stdin: commandSpec.stdin === undefined ? "ignore" : "pipe",
|
|
194
|
-
stdout: "pipe",
|
|
195
|
-
stderr: "pipe",
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
task.process = child;
|
|
199
|
-
task.pid = child.pid;
|
|
200
|
-
|
|
201
265
|
task.streamsDone = Promise.all([
|
|
202
266
|
this.pipeStreamSafely(task, "stdout", child.stdout),
|
|
203
267
|
this.pipeStreamSafely(task, "stderr", child.stderr),
|
|
@@ -325,6 +389,7 @@ export class TaskManager {
|
|
|
325
389
|
|
|
326
390
|
for (const task of this.tasks.values()) {
|
|
327
391
|
this.clearTimers(task);
|
|
392
|
+
this.cleanupIsolatedCwd(task);
|
|
328
393
|
}
|
|
329
394
|
}
|
|
330
395
|
|
|
@@ -366,12 +431,70 @@ export class TaskManager {
|
|
|
366
431
|
message: error instanceof Error ? error.message : String(error),
|
|
367
432
|
});
|
|
368
433
|
} finally {
|
|
434
|
+
await this.collectFinalMessage(task);
|
|
435
|
+
this.collectUsage(task);
|
|
436
|
+
this.cleanupIsolatedCwd(task);
|
|
369
437
|
this.clearTimers(task);
|
|
370
438
|
this.touch(task);
|
|
371
439
|
this.pruneTasks(task.id);
|
|
372
440
|
}
|
|
373
441
|
}
|
|
374
442
|
|
|
443
|
+
private async collectFinalMessage(task: InternalTask): Promise<void> {
|
|
444
|
+
if (!task.finalMessagePath) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
if (existsSync(task.finalMessagePath)) {
|
|
450
|
+
const content = (await readFile(task.finalMessagePath, "utf8")).trim();
|
|
451
|
+
if (content) {
|
|
452
|
+
task.finalMessage = content;
|
|
453
|
+
}
|
|
454
|
+
rmSync(task.finalMessagePath, { force: true });
|
|
455
|
+
}
|
|
456
|
+
} catch (error) {
|
|
457
|
+
this.pushEvent(task, {
|
|
458
|
+
type: "error",
|
|
459
|
+
message: `Failed to read final message file: ${
|
|
460
|
+
error instanceof Error ? error.message : String(error)
|
|
461
|
+
}`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private collectUsage(task: InternalTask): void {
|
|
467
|
+
if (!task.extractUsage) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
task.usage = task.extractUsage(task.stdout, task.stderr);
|
|
473
|
+
} catch {
|
|
474
|
+
task.usage = undefined;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private cleanupIsolatedCwd(task: InternalTask): boolean {
|
|
479
|
+
if (!task.isolatedCwd) {
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
this.removeIsolatedCwd(task.isolatedCwd);
|
|
485
|
+
task.isolatedCwd = undefined;
|
|
486
|
+
return true;
|
|
487
|
+
} catch (error) {
|
|
488
|
+
this.pushEvent(task, {
|
|
489
|
+
type: "error",
|
|
490
|
+
message: `Failed to remove isolated working directory: ${
|
|
491
|
+
error instanceof Error ? error.message : String(error)
|
|
492
|
+
}`,
|
|
493
|
+
});
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
375
498
|
private pruneTasks(protectedTaskId?: string): void {
|
|
376
499
|
const overflow = this.tasks.size - this.maxTasks;
|
|
377
500
|
if (overflow <= 0) {
|
|
@@ -382,8 +505,16 @@ export class TaskManager {
|
|
|
382
505
|
.filter((task) => task.id !== protectedTaskId && task.status !== "running")
|
|
383
506
|
.sort((a, b) => a.createdAtMs - b.createdAtMs);
|
|
384
507
|
|
|
385
|
-
|
|
508
|
+
let removed = 0;
|
|
509
|
+
for (const task of removable) {
|
|
510
|
+
if (removed >= overflow) {
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
if (!this.cleanupIsolatedCwd(task)) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
386
516
|
this.tasks.delete(task.id);
|
|
517
|
+
removed += 1;
|
|
387
518
|
}
|
|
388
519
|
}
|
|
389
520
|
|
|
@@ -569,6 +700,9 @@ export class TaskManager {
|
|
|
569
700
|
stderr: includeOutput ? tail(task.stderr, maxOutputChars) : "",
|
|
570
701
|
events: includeEvents ? tailItems(task.events, maxEvents) : [],
|
|
571
702
|
appliedSkills: task.appliedSkills,
|
|
703
|
+
finalMessage: includeOutput ? task.finalMessage : undefined,
|
|
704
|
+
usage: task.usage,
|
|
705
|
+
isolatedFrom: task.isolatedFrom,
|
|
572
706
|
};
|
|
573
707
|
}
|
|
574
708
|
|
|
@@ -627,3 +761,39 @@ function trimEventMessage(message: string): string {
|
|
|
627
761
|
|
|
628
762
|
return `${message.slice(0, MAX_EVENT_MESSAGE_CHARS)}...`;
|
|
629
763
|
}
|
|
764
|
+
|
|
765
|
+
const ISOLATION_EXCLUDED_DIRS = new Set(["node_modules", ".git"]);
|
|
766
|
+
|
|
767
|
+
function isolateDirectory(source: string): string {
|
|
768
|
+
const target = mkdtempSync(join(tmpdir(), "ennodia-cwd-"));
|
|
769
|
+
try {
|
|
770
|
+
cpSync(source, target, {
|
|
771
|
+
recursive: true,
|
|
772
|
+
filter: (candidate) => {
|
|
773
|
+
if (ISOLATION_EXCLUDED_DIRS.has(basename(candidate))) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
if (lstatSync(candidate).isSymbolicLink()) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
`Cannot isolate working directory containing symbolic link: ${candidate}`,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
return true;
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
return target;
|
|
785
|
+
} catch (error) {
|
|
786
|
+
rmSync(target, { recursive: true, force: true });
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function cleanupFailedStartArtifacts(
|
|
792
|
+
finalMessagePath: string,
|
|
793
|
+
isolatedCwd?: string,
|
|
794
|
+
): void {
|
|
795
|
+
rmSync(finalMessagePath, { force: true });
|
|
796
|
+
if (isolatedCwd) {
|
|
797
|
+
rmSync(isolatedCwd, { recursive: true, force: true });
|
|
798
|
+
}
|
|
799
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const ENNODIA_VERSION = "0.
|
|
1
|
+
export const ENNODIA_VERSION = "0.2.0";
|