@vimhead.dev/norn-cli 0.1.0-tip.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 +13 -0
- package/assets/README.md +157 -0
- package/assets/docs/README.md +23 -0
- package/assets/docs/agents.md +64 -0
- package/assets/docs/cli.md +183 -0
- package/assets/docs/composition.md +76 -0
- package/assets/docs/persistence.md +56 -0
- package/assets/docs/projects.md +75 -0
- package/assets/docs/recovery.md +56 -0
- package/assets/docs/resources.md +61 -0
- package/assets/docs/workflows.md +57 -0
- package/assets/examples/agent-then-analysis/README.md +103 -0
- package/assets/examples/agent-then-analysis/input.json +5 -0
- package/assets/examples/agent-then-analysis/norn.project.json +4 -0
- package/assets/examples/agent-then-analysis/plugin.ts +89 -0
- package/assets/examples/coordinating-multiple-agents/README.md +56 -0
- package/assets/examples/coordinating-multiple-agents/input.json +10 -0
- package/assets/examples/coordinating-multiple-agents/norn.project.json +4 -0
- package/assets/examples/coordinating-multiple-agents/plugin.ts +102 -0
- package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +52 -0
- package/assets/examples/coordinating-multiple-agents/work-queue.ts +153 -0
- package/assets/examples/minimal-workflow/README.md +71 -0
- package/assets/examples/minimal-workflow/norn.project.json +4 -0
- package/assets/examples/minimal-workflow/plugin.ts +29 -0
- package/assets/examples/shared-state/README.md +19 -0
- package/assets/examples/shared-state/input.json +1 -0
- package/assets/examples/shared-state/norn.project.json +4 -0
- package/assets/examples/shared-state/plugin.ts +47 -0
- package/assets/examples/worktree-development-loop/README.md +66 -0
- package/assets/examples/worktree-development-loop/index.ts +1 -0
- package/assets/examples/worktree-development-loop/manifest.ts +26 -0
- package/assets/examples/worktree-development-loop/norn.project.json +9 -0
- package/assets/examples/worktree-development-loop/plugin.ts +27 -0
- package/assets/examples/worktree-development-loop/shared/commands.ts +6 -0
- package/assets/examples/worktree-development-loop/state.ts +23 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +22 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +14 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +54 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/planning/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +28 -0
- package/assets/examples/worktree-development-loop/workflows/planning/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/planning/schema.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/review/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/review/execute.ts +53 -0
- package/assets/examples/worktree-development-loop/workflows/review/index.ts +10 -0
- package/assets/examples/worktree-development-loop/workflows/review/schema.ts +23 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/declaration.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +12 -0
- package/assets/package.json +1 -0
- package/assets/packages/cli/src/build-info.ts +36 -0
- package/assets/packages/cli/src/bun/cli.ts +16 -0
- package/assets/packages/cli/src/cli.ts +1135 -0
- package/assets/packages/cli/src/client.ts +167 -0
- package/assets/packages/cli/src/documentation-intro.ts +30 -0
- package/assets/packages/cli/src/documentation.ts +149 -0
- package/assets/packages/cli/src/generated-build-info.ts +12 -0
- package/assets/packages/cli/src/internal/agent-directory.ts +5 -0
- package/assets/packages/cli/src/internal/agent-response-tool.ts +96 -0
- package/assets/packages/cli/src/internal/agents.ts +365 -0
- package/assets/packages/cli/src/internal/artifacts.ts +26 -0
- package/assets/packages/cli/src/internal/commands.ts +180 -0
- package/assets/packages/cli/src/internal/documentation-bundle.ts +49 -0
- package/assets/packages/cli/src/internal/engine.ts +501 -0
- package/assets/packages/cli/src/internal/errors.ts +39 -0
- package/assets/packages/cli/src/internal/file-names.ts +3 -0
- package/assets/packages/cli/src/internal/launch-request.ts +94 -0
- package/assets/packages/cli/src/internal/logs.ts +41 -0
- package/assets/packages/cli/src/internal/metrics.ts +356 -0
- package/assets/packages/cli/src/internal/pi-assets.ts +95 -0
- package/assets/packages/cli/src/internal/resource-bindings.ts +35 -0
- package/assets/packages/cli/src/internal/run-lease.ts +158 -0
- package/assets/packages/cli/src/internal/run-log.ts +59 -0
- package/assets/packages/cli/src/internal/run-names.ts +36 -0
- package/assets/packages/cli/src/internal/run-resources.ts +23 -0
- package/assets/packages/cli/src/internal/run-state.ts +380 -0
- package/assets/packages/cli/src/internal/run-store.ts +323 -0
- package/assets/packages/cli/src/internal/run.ts +133 -0
- package/assets/packages/cli/src/internal/state-store.ts +75 -0
- package/assets/packages/cli/src/internal/usage.ts +70 -0
- package/assets/packages/cli/src/internal/workflow-registry.ts +176 -0
- package/assets/packages/cli/src/plugin-loader.ts +412 -0
- package/assets/packages/cli/src/resources.ts +67 -0
- package/assets/packages/core/src/agent-protocol.ts +1 -0
- package/assets/packages/core/src/atomic-files.ts +24 -0
- package/assets/packages/core/src/errors.ts +3 -0
- package/assets/packages/sdk/src/agent-resource-adapter.ts +11 -0
- package/assets/packages/sdk/src/api.ts +821 -0
- package/assets/packages/sdk/src/files.ts +136 -0
- package/assets/packages/sdk/src/index.ts +6 -0
- package/assets/packages/sdk/src/resources.ts +20 -0
- package/assets/packages/sdk/src/schema.ts +48 -0
- package/assets/packages/sdk/src/seer/config.ts +62 -0
- package/assets/packages/sdk/src/seer/index.ts +7 -0
- package/assets/packages/sdk/src/state-adapter.ts +75 -0
- package/assets/setup/providers.md +128 -0
- package/assets/setup/releases.md +76 -0
- package/assets/tests/workflow-ref.test.ts +113 -0
- package/bin/norn.mjs +10 -0
- package/dist/build-info.d.ts +30 -0
- package/dist/build-info.js +6 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1032 -0
- package/dist/client.d.ts +48 -0
- package/dist/client.js +118 -0
- package/dist/documentation-intro.d.ts +5 -0
- package/dist/documentation-intro.js +29 -0
- package/dist/documentation.d.ts +33 -0
- package/dist/documentation.js +132 -0
- package/dist/generated-build-info.d.ts +10 -0
- package/dist/generated-build-info.js +14 -0
- package/dist/internal/agent-directory.d.ts +4 -0
- package/dist/internal/agent-directory.js +8 -0
- package/dist/internal/agent-response-tool.d.ts +21 -0
- package/dist/internal/agent-response-tool.js +79 -0
- package/dist/internal/agents.d.ts +29 -0
- package/dist/internal/agents.js +336 -0
- package/dist/internal/artifacts.d.ts +10 -0
- package/dist/internal/artifacts.js +29 -0
- package/dist/internal/commands.d.ts +18 -0
- package/dist/internal/commands.js +147 -0
- package/dist/internal/documentation-bundle.d.ts +16 -0
- package/dist/internal/documentation-bundle.js +42 -0
- package/dist/internal/engine.d.ts +44 -0
- package/dist/internal/engine.js +399 -0
- package/dist/internal/errors.d.ts +14 -0
- package/dist/internal/errors.js +38 -0
- package/dist/internal/file-names.d.ts +1 -0
- package/dist/internal/file-names.js +7 -0
- package/dist/internal/launch-request.d.ts +33 -0
- package/dist/internal/launch-request.js +110 -0
- package/dist/internal/logs.d.ts +16 -0
- package/dist/internal/logs.js +38 -0
- package/dist/internal/metrics.d.ts +19 -0
- package/dist/internal/metrics.js +282 -0
- package/dist/internal/pi-assets.d.ts +13 -0
- package/dist/internal/pi-assets.js +94 -0
- package/dist/internal/resource-bindings.d.ts +13 -0
- package/dist/internal/resource-bindings.js +34 -0
- package/dist/internal/run-lease.d.ts +32 -0
- package/dist/internal/run-lease.js +166 -0
- package/dist/internal/run-log.d.ts +30 -0
- package/dist/internal/run-log.js +71 -0
- package/dist/internal/run-names.d.ts +1 -0
- package/dist/internal/run-names.js +144 -0
- package/dist/internal/run-resources.d.ts +6 -0
- package/dist/internal/run-resources.js +26 -0
- package/dist/internal/run-state.d.ts +95 -0
- package/dist/internal/run-state.js +323 -0
- package/dist/internal/run-store.d.ts +35 -0
- package/dist/internal/run-store.js +314 -0
- package/dist/internal/run.d.ts +51 -0
- package/dist/internal/run.js +101 -0
- package/dist/internal/state-store.d.ts +22 -0
- package/dist/internal/state-store.js +97 -0
- package/dist/internal/usage.d.ts +5 -0
- package/dist/internal/usage.js +70 -0
- package/dist/internal/workflow-registry.d.ts +35 -0
- package/dist/internal/workflow-registry.js +129 -0
- package/dist/plugin-loader.d.ts +55 -0
- package/dist/plugin-loader.js +353 -0
- package/dist/resources.d.ts +11 -0
- package/dist/resources.js +98 -0
- package/package.json +52 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SessionManager,
|
|
3
|
+
createAgentSessionServices,
|
|
4
|
+
createAgentSessionFromServices,
|
|
5
|
+
createEventBus,
|
|
6
|
+
type AgentSession,
|
|
7
|
+
type CreateAgentSessionOptions,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { mkdir } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import type { NornAgentCreateSessionInput, NornAgentPromptInput, NornAgentRunRawAttempt, NornAgentRunResult, NornAgentSessionEvents, NornAgentSession, NornAgentSinglePromptInput, NornAgentUsage } from "@vimhead.dev/norn";
|
|
15
|
+
import { AGENT_RESPONSE_TOOL_NAME } from "@vimhead.dev/norn-core/agent-protocol";
|
|
16
|
+
import {
|
|
17
|
+
NornAgentResponseCollector,
|
|
18
|
+
NornAgentResponseToolFactory,
|
|
19
|
+
type NornCapturedAgentResponse,
|
|
20
|
+
} from "./agent-response-tool.ts";
|
|
21
|
+
import { resolveNornAgentDirectory } from "./agent-directory.ts";
|
|
22
|
+
import { errorMessage } from "./errors.ts";
|
|
23
|
+
import type { NornRunLogs } from "./logs.ts";
|
|
24
|
+
import { safeFileName } from "./file-names.ts";
|
|
25
|
+
import { NornSessionResourceBindings } from "./resource-bindings.ts";
|
|
26
|
+
import type { NornRunLogger } from "./run-log.ts";
|
|
27
|
+
import { agentUsageFromValue, emptyAgentUsage, totalAgentUsage } from "./usage.ts";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_AGENT_ATTEMPTS = 3;
|
|
30
|
+
const DEFAULT_AGENT_TOOL_ALLOWLIST = ["read", "bash", "edit", "write", AGENT_RESPONSE_TOOL_NAME] as const;
|
|
31
|
+
|
|
32
|
+
type NornAgentRunnerInput = {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly runRoot: string;
|
|
35
|
+
readonly boundaryRoot: string;
|
|
36
|
+
readonly boundaryName: string;
|
|
37
|
+
readonly cwd: string;
|
|
38
|
+
readonly signal?: AbortSignal;
|
|
39
|
+
readonly model?: CreateAgentSessionOptions["model"];
|
|
40
|
+
readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
|
|
41
|
+
readonly agentDir?: string;
|
|
42
|
+
readonly logs: NornRunLogs;
|
|
43
|
+
readonly logger: NornRunLogger;
|
|
44
|
+
readonly responseCollector: NornAgentResponseCollector;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type CreatedNornAgentSessionInput = NornAgentRunnerInput & {
|
|
48
|
+
readonly label: string;
|
|
49
|
+
readonly cwd: string;
|
|
50
|
+
readonly session: AgentSession;
|
|
51
|
+
readonly resourceBindings: NornSessionResourceBindings;
|
|
52
|
+
readonly events: NornAgentSessionEvents;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class NornAgentRunner {
|
|
56
|
+
private readonly responseToolFactory: NornAgentResponseToolFactory;
|
|
57
|
+
|
|
58
|
+
constructor(private readonly input: NornAgentRunnerInput) {
|
|
59
|
+
this.responseToolFactory = new NornAgentResponseToolFactory(input.responseCollector);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async createSession(agentInput: NornAgentCreateSessionInput): Promise<NornAgentSession> {
|
|
63
|
+
const cwd = agentInput.cwd ? this.resolveFromCwd(agentInput.cwd) : this.input.cwd;
|
|
64
|
+
const sessionDir = resolve(this.input.runRoot, "sessions");
|
|
65
|
+
await mkdir(sessionDir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
const eventBus = createEventBus();
|
|
68
|
+
const agentDir = this.input.agentDir ?? resolveNornAgentDirectory({ home: homedir(), environment: process.env });
|
|
69
|
+
const services = await createAgentSessionServices({
|
|
70
|
+
cwd,
|
|
71
|
+
agentDir,
|
|
72
|
+
resourceLoaderOptions: {
|
|
73
|
+
eventBus,
|
|
74
|
+
systemPromptOverride: systemPromptOverride(agentInput),
|
|
75
|
+
appendSystemPromptOverride: appendSystemPromptOverride(agentInput),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const loader = services.resourceLoader;
|
|
79
|
+
const errors = [
|
|
80
|
+
...services.diagnostics.filter(diagnostic => diagnostic.type === "error").map(diagnostic => diagnostic.message),
|
|
81
|
+
...loader.getExtensions().errors.map(error => `Failed to load extension ${error.path}: ${error.error}`),
|
|
82
|
+
];
|
|
83
|
+
if (errors.length > 0) throw new Error(errors.join("\n"));
|
|
84
|
+
const resourceBindings = new NornSessionResourceBindings();
|
|
85
|
+
let session: AgentSession | undefined;
|
|
86
|
+
try {
|
|
87
|
+
await resourceBindings.bind({
|
|
88
|
+
adapters: agentInput.resourceAdapters ?? [], runId: this.input.id, label: agentInput.label,
|
|
89
|
+
reservedTools: ["read", "bash", "edit", "write", "grep", "find", "ls", "powershell", AGENT_RESPONSE_TOOL_NAME,
|
|
90
|
+
...loader.getExtensions().extensions.flatMap((extension) => [...extension.tools.keys()])],
|
|
91
|
+
});
|
|
92
|
+
({ session } = await createAgentSessionFromServices({
|
|
93
|
+
services,
|
|
94
|
+
sessionManager: SessionManager.create(cwd, sessionDir),
|
|
95
|
+
tools: [...withAgentResponseTool(agentInput.tools), ...resourceBindings.tools.map((tool) => tool.name)],
|
|
96
|
+
customTools: [this.responseToolFactory.create(), ...resourceBindings.tools],
|
|
97
|
+
model: agentInput.model ?? this.input.model,
|
|
98
|
+
thinkingLevel: agentInput.thinkingLevel ?? this.input.thinkingLevel,
|
|
99
|
+
}));
|
|
100
|
+
await agentInput.beforeSessionStart?.({ events: eventBus });
|
|
101
|
+
await session.bindExtensions({});
|
|
102
|
+
await this.input.logger.record({ type: "agent.spawned", label: agentInput.label, cwd });
|
|
103
|
+
return new CreatedNornAgentSession({
|
|
104
|
+
...this.input, label: agentInput.label, cwd, session, resourceBindings, events: eventBus,
|
|
105
|
+
});
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const errors = [error];
|
|
108
|
+
try { session?.dispose(); } catch (cleanupError) { errors.push(cleanupError); }
|
|
109
|
+
try { await resourceBindings.dispose(); } catch (cleanupError) { errors.push(cleanupError); }
|
|
110
|
+
if (errors.length > 1) throw new AggregateError(errors, "Agent creation and resource cleanup failed");
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async prompt<ResponseSchema extends z.ZodType>(agentInput: NornAgentSinglePromptInput<ResponseSchema>): Promise<z.output<ResponseSchema>> {
|
|
116
|
+
const agent = await this.createSession(agentInput);
|
|
117
|
+
try {
|
|
118
|
+
return await agent.prompt(agentInput);
|
|
119
|
+
} finally {
|
|
120
|
+
await agent.dispose();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private resolveFromCwd(path: string): string {
|
|
125
|
+
const resolvedPath = isAbsolute(path) ? path : resolve(this.input.cwd, path);
|
|
126
|
+
const pathFromBoundary = relative(this.input.boundaryRoot, resolvedPath);
|
|
127
|
+
if (pathFromBoundary === ".." || pathFromBoundary.startsWith(`..${sep}`) || isAbsolute(pathFromBoundary)) {
|
|
128
|
+
throw new Error(`Agent cwd escapes ${this.input.boundaryName} isolation: ${path}`);
|
|
129
|
+
}
|
|
130
|
+
return resolvedPath;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function systemPromptOverride(agentInput: NornAgentCreateSessionInput): (() => string) | undefined {
|
|
135
|
+
const systemPrompt = agentInput.systemPrompt;
|
|
136
|
+
return systemPrompt === undefined ? undefined : () => systemPrompt;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function appendSystemPromptOverride(agentInput: NornAgentCreateSessionInput): ((base: string[]) => string[]) | undefined {
|
|
140
|
+
const appendSystemPrompt = agentInput.appendSystemPrompt;
|
|
141
|
+
return appendSystemPrompt === undefined ? undefined : (base) => [...base, ...appendSystemPrompt];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class CreatedNornAgentSession implements NornAgentSession {
|
|
145
|
+
readonly label: string;
|
|
146
|
+
readonly cwd: string;
|
|
147
|
+
readonly events: NornAgentSessionEvents;
|
|
148
|
+
private isDisposed = false;
|
|
149
|
+
|
|
150
|
+
constructor(private readonly input: CreatedNornAgentSessionInput) {
|
|
151
|
+
this.label = input.label;
|
|
152
|
+
this.cwd = input.cwd;
|
|
153
|
+
this.events = input.events;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async prompt<ResponseSchema extends z.ZodType>(agentInput: NornAgentPromptInput<ResponseSchema>): Promise<z.output<ResponseSchema>> {
|
|
157
|
+
return (await this.promptWithResult(agentInput)).response;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async promptWithResult<ResponseSchema extends z.ZodType>(agentInput: NornAgentPromptInput<ResponseSchema>): Promise<NornAgentRunResult<ResponseSchema>> {
|
|
161
|
+
if (this.isDisposed) throw new Error(`Workflow agent session is disposed: ${this.label}`);
|
|
162
|
+
const maxAttempts = Math.max(1, Math.floor(agentInput.maxAttempts ?? DEFAULT_AGENT_ATTEMPTS));
|
|
163
|
+
const attempts: NornAgentRunRawAttempt[] = [];
|
|
164
|
+
const startedAtMs = Date.now();
|
|
165
|
+
let isTerminalEventRecorded = false;
|
|
166
|
+
await this.input.logger.record({ type: "agent.started", label: this.label, cwd: this.cwd, maxAttempts });
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
170
|
+
await this.input.logger.record({ type: "agent.attempt.started", label: this.label, attempt });
|
|
171
|
+
const raw = await this.runAttempt(agentInput, attempt, attempts.at(-1)?.error);
|
|
172
|
+
const rawAttempt: NornAgentRunRawAttempt = { attempt, ...raw };
|
|
173
|
+
attempts.push(rawAttempt);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (!raw.responseToolCalled) throw new Error(`Agent did not call ${AGENT_RESPONSE_TOOL_NAME}`);
|
|
177
|
+
const response = agentInput.response.parse(raw.toolResponse);
|
|
178
|
+
const usage = totalAgentUsage(attempts.map((candidate) => candidate.usage));
|
|
179
|
+
const result: NornAgentRunResult<ResponseSchema> = {
|
|
180
|
+
label: this.label,
|
|
181
|
+
cwd: this.cwd,
|
|
182
|
+
response,
|
|
183
|
+
usage,
|
|
184
|
+
raw: { ...raw, usage, attempts },
|
|
185
|
+
};
|
|
186
|
+
await this.input.logs.write(`agents/${safeFileName(this.label)}.json`, JSON.stringify(result, null, 2));
|
|
187
|
+
isTerminalEventRecorded = true;
|
|
188
|
+
await this.input.logger.record({ type: "agent.completed", label: this.label, attempts: attempt, durationMs: Date.now() - startedAtMs, usage });
|
|
189
|
+
return result;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const message = errorMessage(error);
|
|
192
|
+
attempts[attempts.length - 1] = { ...rawAttempt, error: message };
|
|
193
|
+
await this.input.logs.write(
|
|
194
|
+
`agents/${safeFileName(this.label)}.attempt-${attempt}.raw.json`,
|
|
195
|
+
JSON.stringify({ label: this.label, cwd: this.cwd, raw: attempts.at(-1) }, null, 2),
|
|
196
|
+
);
|
|
197
|
+
await this.input.logger.record({ type: "agent.attempt.failed", label: this.label, attempt, error: message });
|
|
198
|
+
if (attempt === maxAttempts) {
|
|
199
|
+
const usage = totalAgentUsage(attempts.map((candidate) => candidate.usage));
|
|
200
|
+
isTerminalEventRecorded = true;
|
|
201
|
+
await this.input.logger.record({ type: "agent.failed", label: this.label, attempts: attempt, durationMs: Date.now() - startedAtMs, usage, error: message });
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Agent ${this.label} did not return a valid structured response after ${attempt} attempt(s). Raw output saved to logs/agents/${safeFileName(this.label)}.attempt-${attempt}.raw.json: ${message}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
throw new Error(`Agent ${this.label} did not run`);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!isTerminalEventRecorded) {
|
|
212
|
+
await this.input.logger.record({
|
|
213
|
+
type: "agent.failed",
|
|
214
|
+
label: this.label,
|
|
215
|
+
attempts: attempts.length,
|
|
216
|
+
durationMs: Date.now() - startedAtMs,
|
|
217
|
+
usage: totalAgentUsage(attempts.map((candidate) => candidate.usage)),
|
|
218
|
+
error: errorMessage(error),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async dispose(): Promise<void> {
|
|
226
|
+
if (this.isDisposed) return;
|
|
227
|
+
this.isDisposed = true;
|
|
228
|
+
const errors: unknown[] = [];
|
|
229
|
+
try { await this.input.session.abort(); } catch (error) { errors.push(error); }
|
|
230
|
+
try { this.input.session.dispose(); } catch (error) { errors.push(error); }
|
|
231
|
+
try { await this.input.resourceBindings.dispose(); } catch (error) { errors.push(error); }
|
|
232
|
+
if (errors.length) throw new AggregateError(errors, "Agent disposal failed");
|
|
233
|
+
await this.input.logger.record({ type: "agent.disposed", label: this.label });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async runAttempt<ResponseSchema extends z.ZodType>(
|
|
237
|
+
agentInput: NornAgentPromptInput<ResponseSchema>,
|
|
238
|
+
attempt: number,
|
|
239
|
+
previousError: string | undefined,
|
|
240
|
+
): Promise<Omit<NornAgentRunRawAttempt, "attempt" | "error">> {
|
|
241
|
+
const responseRunId = `${this.input.id}:${attempt}:${randomUUID()}`;
|
|
242
|
+
const messages: unknown[] = [];
|
|
243
|
+
let text = "";
|
|
244
|
+
const releaseResponseSlot = this.input.responseCollector.begin(responseRunId, this.label, agentInput.response);
|
|
245
|
+
const unsubscribe = this.input.session.subscribe((event) => {
|
|
246
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
247
|
+
text += event.assistantMessageEvent.delta;
|
|
248
|
+
}
|
|
249
|
+
if (event.type === "message_end" && event.message) messages.push(event.message);
|
|
250
|
+
});
|
|
251
|
+
const abortNestedSession = () => {
|
|
252
|
+
void this.input.session.abort();
|
|
253
|
+
};
|
|
254
|
+
this.input.signal?.addEventListener("abort", abortNestedSession, { once: true });
|
|
255
|
+
|
|
256
|
+
let capturedResponse: NornCapturedAgentResponse = { called: false };
|
|
257
|
+
try {
|
|
258
|
+
this.input.signal?.throwIfAborted();
|
|
259
|
+
await this.input.session.prompt(withResponseToolInstruction(agentInput.prompt, agentInput.response, this.label, responseRunId, attempt, previousError), {
|
|
260
|
+
...agentInput.options,
|
|
261
|
+
source: agentInput.options?.source ?? "extension",
|
|
262
|
+
});
|
|
263
|
+
this.input.signal?.throwIfAborted();
|
|
264
|
+
capturedResponse = this.input.responseCollector.get(responseRunId);
|
|
265
|
+
if (!capturedResponse.called) {
|
|
266
|
+
await this.promptForStructuredResponse(agentInput, responseRunId, attempt, previousError);
|
|
267
|
+
this.input.signal?.throwIfAborted();
|
|
268
|
+
capturedResponse = this.input.responseCollector.get(responseRunId);
|
|
269
|
+
}
|
|
270
|
+
} finally {
|
|
271
|
+
this.input.signal?.removeEventListener("abort", abortNestedSession);
|
|
272
|
+
releaseResponseSlot();
|
|
273
|
+
unsubscribe();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
text: text.trim(),
|
|
278
|
+
messages,
|
|
279
|
+
responseToolCalled: capturedResponse.called,
|
|
280
|
+
usage: usageFromMessages(messages),
|
|
281
|
+
toolResponse: capturedResponse.response,
|
|
282
|
+
sessionFile: this.input.session.sessionFile,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async promptForStructuredResponse<ResponseSchema extends z.ZodType>(
|
|
287
|
+
agentInput: NornAgentPromptInput<ResponseSchema>,
|
|
288
|
+
responseRunId: string,
|
|
289
|
+
attempt: number,
|
|
290
|
+
previousError: string | undefined,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
const activeToolNames = this.input.session.getActiveToolNames();
|
|
293
|
+
await this.input.logger.record({ type: "agent.response-finalization.started", label: this.label, attempt });
|
|
294
|
+
this.input.session.setActiveToolsByName([AGENT_RESPONSE_TOOL_NAME]);
|
|
295
|
+
try {
|
|
296
|
+
await this.input.session.prompt(withResponseToolFinalizationInstruction(agentInput.response, this.label, responseRunId, attempt, previousError), {
|
|
297
|
+
...agentInput.options,
|
|
298
|
+
source: agentInput.options?.source ?? "extension",
|
|
299
|
+
});
|
|
300
|
+
await this.input.logger.record({ type: "agent.response-finalization.completed", label: this.label, attempt });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
await this.input.logger.record({ type: "agent.response-finalization.failed", label: this.label, attempt, error: errorMessage(error) });
|
|
303
|
+
throw error;
|
|
304
|
+
} finally {
|
|
305
|
+
this.input.session.setActiveToolsByName(activeToolNames);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function usageFromMessages(messages: readonly unknown[]): NornAgentUsage {
|
|
311
|
+
return totalAgentUsage(messages.map(usageFromMessage).filter((usage): usage is NornAgentUsage => usage !== undefined));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function usageFromMessage(message: unknown): NornAgentUsage | undefined {
|
|
315
|
+
if (!message || typeof message !== "object" || !("usage" in message)) return undefined;
|
|
316
|
+
return agentUsageFromValue((message as { usage?: unknown }).usage) ?? emptyAgentUsage();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function withResponseToolInstruction(
|
|
320
|
+
prompt: string,
|
|
321
|
+
responseSchema: z.ZodType,
|
|
322
|
+
label: string,
|
|
323
|
+
responseRunId: string,
|
|
324
|
+
attempt: number,
|
|
325
|
+
previousError: string | undefined,
|
|
326
|
+
): string {
|
|
327
|
+
return [
|
|
328
|
+
prompt,
|
|
329
|
+
"",
|
|
330
|
+
"Structured workflow response:",
|
|
331
|
+
`Use the ${AGENT_RESPONSE_TOOL_NAME} tool as your final action to record the workflow response.`,
|
|
332
|
+
"Do not emit the workflow response as assistant text or Markdown.",
|
|
333
|
+
`Pass runId exactly as: ${responseRunId}`,
|
|
334
|
+
`Pass label exactly as: ${label}`,
|
|
335
|
+
"Pass the structured workflow response in the tool's response argument.",
|
|
336
|
+
"The response argument must match this JSON Schema:",
|
|
337
|
+
JSON.stringify(z.toJSONSchema(responseSchema), null, 2),
|
|
338
|
+
...(attempt > 1 && previousError ? ["", `Previous structured response attempt failed: ${previousError}`] : []),
|
|
339
|
+
].join("\n");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function withResponseToolFinalizationInstruction(
|
|
343
|
+
responseSchema: z.ZodType,
|
|
344
|
+
label: string,
|
|
345
|
+
responseRunId: string,
|
|
346
|
+
attempt: number,
|
|
347
|
+
previousError: string | undefined,
|
|
348
|
+
): string {
|
|
349
|
+
return [
|
|
350
|
+
"Your previous workflow response turn ended without recording the structured response.",
|
|
351
|
+
`Use the ${AGENT_RESPONSE_TOOL_NAME} tool now. It is the only active tool for this turn.`,
|
|
352
|
+
"Do not answer in assistant text or Markdown.",
|
|
353
|
+
`Pass runId exactly as: ${responseRunId}`,
|
|
354
|
+
`Pass label exactly as: ${label}`,
|
|
355
|
+
"Pass the structured workflow response in the tool's response argument, based on the work you already completed in this session.",
|
|
356
|
+
"The response argument must match this JSON Schema:",
|
|
357
|
+
JSON.stringify(z.toJSONSchema(responseSchema), null, 2),
|
|
358
|
+
...(attempt > 1 && previousError ? ["", `Previous structured response attempt failed: ${previousError}`] : []),
|
|
359
|
+
].join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function withAgentResponseTool(tools: readonly string[] | undefined): string[] {
|
|
363
|
+
if (!tools) return [...DEFAULT_AGENT_TOOL_ALLOWLIST];
|
|
364
|
+
return Array.from(new Set([...tools, AGENT_RESPONSE_TOOL_NAME]));
|
|
365
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
|
|
3
|
+
import type { NornArtifactRef } from "@vimhead.dev/norn";
|
|
4
|
+
|
|
5
|
+
export class NornArtifacts {
|
|
6
|
+
constructor(private readonly artifactsRoot: string, private readonly files: NornFileCoordinator) {}
|
|
7
|
+
|
|
8
|
+
async write(path: string, content: string): Promise<NornArtifactRef> {
|
|
9
|
+
await this.files.writeText(this.resolveArtifactPath(path), content);
|
|
10
|
+
return { path };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async read(ref: NornArtifactRef): Promise<string> {
|
|
14
|
+
return this.files.readText(this.resolveArtifactPath(ref.path));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private resolveArtifactPath(path: string): string {
|
|
18
|
+
if (isAbsolute(path)) throw new Error(`Artifact path must be relative: ${path}`);
|
|
19
|
+
const resolvedPath = resolve(this.artifactsRoot, path);
|
|
20
|
+
const relativePath = relative(this.artifactsRoot, resolvedPath);
|
|
21
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
22
|
+
throw new Error(`Artifact path escapes artifacts directory: ${path}`);
|
|
23
|
+
}
|
|
24
|
+
return resolvedPath;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import type { WriteStream } from "node:fs";
|
|
5
|
+
import type { NornCommandRunInput, NornCommandRunResult, NornLogRef } from "@vimhead.dev/norn";
|
|
6
|
+
import { errorMessage } from "./errors.ts";
|
|
7
|
+
import { safeFileName } from "./file-names.ts";
|
|
8
|
+
import type { NornRunLogs } from "./logs.ts";
|
|
9
|
+
import type { NornRunLogger } from "./run-log.ts";
|
|
10
|
+
|
|
11
|
+
type NornCommandRunnerInput = {
|
|
12
|
+
readonly boundaryRoot: string;
|
|
13
|
+
readonly boundaryName: string;
|
|
14
|
+
readonly cwd: string;
|
|
15
|
+
readonly signal?: AbortSignal;
|
|
16
|
+
readonly logs: NornRunLogs;
|
|
17
|
+
readonly logger: NornRunLogger;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class NornCommandRunner {
|
|
21
|
+
constructor(private readonly input: NornCommandRunnerInput) {}
|
|
22
|
+
|
|
23
|
+
async run(commandInput: NornCommandRunInput): Promise<NornCommandRunResult> {
|
|
24
|
+
const cwd = this.resolveFromCwd(commandInput.cwd ?? this.input.cwd);
|
|
25
|
+
const startedAtMs = Date.now();
|
|
26
|
+
const invocationId = randomUUID();
|
|
27
|
+
const stdoutRef = commandLog({ label: commandInput.label, invocationId, stream: "stdout" });
|
|
28
|
+
const stderrRef = commandLog({ label: commandInput.label, invocationId, stream: "stderr" });
|
|
29
|
+
await this.input.logger.record({ type: "command.started", invocationId, label: commandInput.label, command: commandInput.command, cwd, stdoutLogId: stdoutRef.id, stderrLogId: stderrRef.id });
|
|
30
|
+
let result: SpawnCommandResult;
|
|
31
|
+
const stdoutLog = await this.input.logs.createWriteStream(stdoutRef);
|
|
32
|
+
const stderrLog = await this.input.logs.createWriteStream(stderrRef);
|
|
33
|
+
try {
|
|
34
|
+
result = await spawnCommand({
|
|
35
|
+
command: commandInput.command,
|
|
36
|
+
cwd,
|
|
37
|
+
env: { ...process.env, ...(commandInput.env ?? {}) },
|
|
38
|
+
timeoutMs: commandInput.timeoutMs,
|
|
39
|
+
signal: this.input.signal,
|
|
40
|
+
stdoutStream: stdoutLog.stream,
|
|
41
|
+
stderrStream: stderrLog.stream,
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
await this.input.logger.record({ type: "command.failed", invocationId, label: commandInput.label, durationMs: Date.now() - startedAtMs, error: errorMessage(error) });
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const commandResult = {
|
|
48
|
+
label: commandInput.label,
|
|
49
|
+
command: commandInput.command,
|
|
50
|
+
cwd,
|
|
51
|
+
exitCode: result.exitCode,
|
|
52
|
+
stdoutTail: result.stdoutTail,
|
|
53
|
+
stderrTail: result.stderrTail,
|
|
54
|
+
killed: result.killed,
|
|
55
|
+
stdoutLog: stdoutLog.log,
|
|
56
|
+
stderrLog: stderrLog.log,
|
|
57
|
+
};
|
|
58
|
+
await this.input.logger.record({
|
|
59
|
+
type: "command.completed",
|
|
60
|
+
invocationId,
|
|
61
|
+
label: commandInput.label,
|
|
62
|
+
durationMs: Date.now() - startedAtMs,
|
|
63
|
+
exitCode: result.exitCode,
|
|
64
|
+
killed: result.killed,
|
|
65
|
+
stdoutLogId: commandResult.stdoutLog.id,
|
|
66
|
+
stderrLogId: commandResult.stderrLog.id,
|
|
67
|
+
});
|
|
68
|
+
return commandResult;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private resolveFromCwd(path: string): string {
|
|
72
|
+
const resolvedPath = isAbsolute(path) ? path : resolve(this.input.cwd, path);
|
|
73
|
+
const pathFromBoundary = relative(this.input.boundaryRoot, resolvedPath);
|
|
74
|
+
if (pathFromBoundary === ".." || pathFromBoundary.startsWith(`..${sep}`) || isAbsolute(pathFromBoundary)) {
|
|
75
|
+
throw new Error(`Command cwd escapes ${this.input.boundaryName} isolation: ${path}`);
|
|
76
|
+
}
|
|
77
|
+
return resolvedPath;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type SpawnCommandInput = {
|
|
82
|
+
command: string | readonly [string, ...string[]];
|
|
83
|
+
cwd: string;
|
|
84
|
+
env: NodeJS.ProcessEnv;
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
signal?: AbortSignal;
|
|
87
|
+
stdoutStream: WriteStream;
|
|
88
|
+
stderrStream: WriteStream;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type SpawnCommandResult = {
|
|
92
|
+
exitCode: number | null;
|
|
93
|
+
stdoutTail: string;
|
|
94
|
+
stderrTail: string;
|
|
95
|
+
killed: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
async function spawnCommand(input: SpawnCommandInput): Promise<SpawnCommandResult> {
|
|
99
|
+
const command = typeof input.command === "string" ? "bash" : input.command[0];
|
|
100
|
+
const args = typeof input.command === "string" ? ["-c", input.command] : input.command.slice(1);
|
|
101
|
+
let killed = false;
|
|
102
|
+
const stdout = new BoundedTextBuffer();
|
|
103
|
+
const stderr = new BoundedTextBuffer();
|
|
104
|
+
|
|
105
|
+
return new Promise((resolvePromise, reject) => {
|
|
106
|
+
const child = spawn(command, args, { cwd: input.cwd, env: input.env, shell: false });
|
|
107
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
108
|
+
let escalation: ReturnType<typeof setTimeout> | undefined;
|
|
109
|
+
let isSettled = false;
|
|
110
|
+
const cleanup = () => {
|
|
111
|
+
if (timeout) clearTimeout(timeout);
|
|
112
|
+
if (escalation) clearTimeout(escalation);
|
|
113
|
+
input.signal?.removeEventListener("abort", killChild);
|
|
114
|
+
};
|
|
115
|
+
const finish = (result: SpawnCommandResult) => {
|
|
116
|
+
if (isSettled) return;
|
|
117
|
+
isSettled = true;
|
|
118
|
+
cleanup();
|
|
119
|
+
void closeStreams(input.stdoutStream, input.stderrStream).then(() => resolvePromise(result), reject);
|
|
120
|
+
};
|
|
121
|
+
const fail = (error: Error) => {
|
|
122
|
+
if (isSettled) return;
|
|
123
|
+
isSettled = true;
|
|
124
|
+
cleanup();
|
|
125
|
+
void closeStreams(input.stdoutStream, input.stderrStream).then(() => reject(error), reject);
|
|
126
|
+
};
|
|
127
|
+
const killChild = () => {
|
|
128
|
+
if (isSettled || killed) return;
|
|
129
|
+
killed = true;
|
|
130
|
+
child.kill("SIGTERM");
|
|
131
|
+
escalation = setTimeout(() => {
|
|
132
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
133
|
+
}, 5_000);
|
|
134
|
+
escalation.unref?.();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (input.timeoutMs !== undefined) timeout = setTimeout(killChild, input.timeoutMs);
|
|
138
|
+
if (input.signal?.aborted) killChild();
|
|
139
|
+
input.signal?.addEventListener("abort", killChild, { once: true });
|
|
140
|
+
|
|
141
|
+
child.stdout?.on("data", (chunk) => {
|
|
142
|
+
const text = chunk.toString();
|
|
143
|
+
stdout.append(text);
|
|
144
|
+
input.stdoutStream.write(text);
|
|
145
|
+
});
|
|
146
|
+
child.stderr?.on("data", (chunk) => {
|
|
147
|
+
const text = chunk.toString();
|
|
148
|
+
stderr.append(text);
|
|
149
|
+
input.stderrStream.write(text);
|
|
150
|
+
});
|
|
151
|
+
child.on("error", fail);
|
|
152
|
+
child.on("close", (code) => finish({ exitCode: code, stdoutTail: stdout.value(), stderrTail: stderr.value(), killed }));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function commandLog(input: { readonly label: string; readonly invocationId: string; readonly stream: "stdout" | "stderr" }): NornLogRef {
|
|
157
|
+
return { id: `commands/${safeFileName(input.label)}-${input.invocationId}.${input.stream}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
class BoundedTextBuffer {
|
|
161
|
+
private text = "";
|
|
162
|
+
|
|
163
|
+
constructor(private readonly maxChars = 128_000) {}
|
|
164
|
+
|
|
165
|
+
append(value: string): void {
|
|
166
|
+
this.text += value;
|
|
167
|
+
if (this.text.length > this.maxChars) this.text = this.text.slice(this.text.length - this.maxChars);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
value(): string {
|
|
171
|
+
return this.text;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function closeStreams(...streams: WriteStream[]): Promise<void> {
|
|
176
|
+
await Promise.all(streams.map((stream) => new Promise<void>((resolvePromise, reject) => {
|
|
177
|
+
stream.on("error", reject);
|
|
178
|
+
stream.end(resolvePromise);
|
|
179
|
+
})));
|
|
180
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type NornDocumentationFile = {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly content: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type NornDocumentationBundle = {
|
|
9
|
+
readonly version: string;
|
|
10
|
+
readonly files: readonly NornDocumentationFile[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const DOCUMENTATION_PATHS = {
|
|
14
|
+
readme: "README.md",
|
|
15
|
+
index: "docs/README.md",
|
|
16
|
+
docs: "docs",
|
|
17
|
+
examples: "examples",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export function validateDocumentationBundle(bundle: NornDocumentationBundle): void {
|
|
21
|
+
if (typeof bundle.version !== "string" || bundle.version.length === 0) throw new Error("Documentation bundle version must not be empty");
|
|
22
|
+
const paths = new Set<string>();
|
|
23
|
+
for (const file of bundle.files) {
|
|
24
|
+
if (typeof file.content !== "string") throw new Error(`Documentation asset must contain text: ${file.path}`);
|
|
25
|
+
if (typeof file.path !== "string" || !/^[a-zA-Z0-9._/-]+$/.test(file.path) || file.path.split("/").some(part => part === "" || part === "." || part === ".." || part.endsWith(".") || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) {
|
|
26
|
+
throw new Error(`Invalid documentation asset path: ${file.path}`);
|
|
27
|
+
}
|
|
28
|
+
const portablePath = file.path.toLowerCase();
|
|
29
|
+
if (paths.has(portablePath)) throw new Error(`Duplicate documentation asset path: ${file.path}`);
|
|
30
|
+
paths.add(portablePath);
|
|
31
|
+
}
|
|
32
|
+
for (const path of paths) {
|
|
33
|
+
const parts = path.split("/");
|
|
34
|
+
while (parts.pop() && parts.length > 0) {
|
|
35
|
+
if (paths.has(parts.join("/"))) throw new Error(`Documentation file/directory collision: ${path}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const path of [DOCUMENTATION_PATHS.readme, DOCUMENTATION_PATHS.index]) {
|
|
39
|
+
if (!bundle.files.some(file => file.path === path)) throw new Error(`Missing documentation asset: ${path}`);
|
|
40
|
+
}
|
|
41
|
+
if (!bundle.files.some(file => file.path.startsWith("examples/"))) throw new Error("Missing documentation examples");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hashDocumentationBundle(bundle: NornDocumentationBundle): string {
|
|
45
|
+
return createHash("sha256").update(JSON.stringify({
|
|
46
|
+
version: bundle.version,
|
|
47
|
+
files: bundle.files.map(file => ({ path: file.path, content: file.content })).sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0),
|
|
48
|
+
})).digest("hex");
|
|
49
|
+
}
|