@vimhead.dev/norn-cli 0.1.0-tip.35614826665.1 → 0.1.0-tip.35724621678.1
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/assets/README.md +41 -13
- package/assets/docs/README.md +16 -16
- package/assets/docs/agents.md +23 -15
- package/assets/docs/cli.md +10 -8
- package/assets/docs/composition.md +7 -7
- package/assets/docs/persistence.md +22 -22
- package/assets/docs/projects.md +6 -6
- package/assets/docs/recovery.md +10 -10
- package/assets/docs/schemas.md +42 -36
- package/assets/docs/workflows.md +39 -32
- package/assets/examples/agent-then-analysis/input.json +3 -3
- package/assets/examples/agent-then-analysis/norn.project.json +2 -2
- package/assets/examples/agent-then-analysis/plugin.ts +37 -9
- package/assets/examples/caller-owned-routing/README.md +6 -6
- package/assets/examples/caller-owned-routing/assessment.ts +18 -4
- package/assets/examples/caller-owned-routing/contracts.ts +7 -2
- package/assets/examples/caller-owned-routing/input.json +12 -12
- package/assets/examples/caller-owned-routing/norn.project.json +2 -2
- package/assets/examples/caller-owned-routing/revision.ts +7 -2
- package/assets/examples/caller-owned-routing/router.ts +14 -3
- package/assets/examples/caller-selected-continuation/caller.ts +38 -12
- package/assets/examples/caller-selected-continuation/input.json +7 -7
- package/assets/examples/caller-selected-continuation/norn.project.json +2 -2
- package/assets/examples/caller-selected-continuation/producer.ts +10 -3
- package/assets/examples/coordinating-multiple-agents/README.md +6 -6
- package/assets/examples/coordinating-multiple-agents/input.json +20 -8
- package/assets/examples/coordinating-multiple-agents/norn.project.json +2 -2
- package/assets/examples/coordinating-multiple-agents/plugin.ts +165 -46
- package/assets/examples/coordinating-multiple-agents/queue-tools.ts +30 -7
- package/assets/examples/coordinating-multiple-agents/work-queue.ts +211 -63
- package/assets/examples/getting-started/norn.project.json +2 -2
- package/assets/examples/getting-started/plugin.ts +33 -13
- package/assets/examples/minimal-workflow/norn.project.json +2 -2
- package/assets/examples/minimal-workflow/plugin.ts +12 -3
- package/assets/examples/shared-state/README.md +6 -6
- package/assets/examples/shared-state/input.json +1 -1
- package/assets/examples/shared-state/norn.project.json +2 -2
- package/assets/examples/shared-state/plugin.ts +44 -15
- package/assets/examples/shared-state/shared-state.ts +46 -13
- package/assets/examples/shared-state/state-tools.ts +68 -16
- package/assets/examples/worktree-development-loop/README.md +12 -12
- package/assets/examples/worktree-development-loop/norn.project.json +7 -7
- package/assets/examples/worktree-development-loop/plugin.ts +7 -1
- package/assets/examples/worktree-development-loop/scope.ts +4 -1
- package/assets/examples/worktree-development-loop/shared/commands.ts +6 -2
- package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -4
- package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -1
- package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +11 -1
- package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +6 -2
- package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +40 -10
- package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +5 -1
- package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +1 -1
- package/assets/examples/worktree-development-loop/workflows/planning/index.ts +5 -1
- package/assets/examples/worktree-development-loop/workflows/review/execute.ts +33 -9
- package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -8
- package/assets/package.json +1 -1
- package/assets/packages/cli/src/build-info.ts +4 -1
- package/assets/packages/cli/src/bun/cli.ts +21 -5
- package/assets/packages/cli/src/cli.ts +585 -170
- package/assets/packages/cli/src/client.ts +194 -36
- package/assets/packages/cli/src/documentation.ts +135 -30
- package/assets/packages/cli/src/generated-build-info.ts +2 -2
- package/assets/packages/cli/src/internal/agent-directory.ts +7 -2
- package/assets/packages/cli/src/internal/agent-response-tool.ts +57 -15
- package/assets/packages/cli/src/internal/agents.ts +248 -60
- package/assets/packages/cli/src/internal/commands.ts +82 -20
- package/assets/packages/cli/src/internal/documentation-bundle.ts +44 -13
- package/assets/packages/cli/src/internal/engine.ts +396 -87
- package/assets/packages/cli/src/internal/errors.ts +13 -5
- package/assets/packages/cli/src/internal/execution-context.ts +20 -12
- package/assets/packages/cli/src/internal/file-coordinator.ts +95 -26
- package/assets/packages/cli/src/internal/file-names.ts +4 -1
- package/assets/packages/cli/src/internal/launch-request.ts +74 -22
- package/assets/packages/cli/src/internal/logs.ts +10 -2
- package/assets/packages/cli/src/internal/metrics.ts +247 -61
- package/assets/packages/cli/src/internal/pi-assets.ts +105 -23
- package/assets/packages/cli/src/internal/run-lease.ts +72 -22
- package/assets/packages/cli/src/internal/run-log.ts +49 -20
- package/assets/packages/cli/src/internal/run-names.ts +120 -12
- package/assets/packages/cli/src/internal/run-state.ts +306 -71
- package/assets/packages/cli/src/internal/run-store.ts +216 -53
- package/assets/packages/cli/src/internal/usage.ts +23 -7
- package/assets/packages/cli/src/internal/worker-directory.ts +9 -3
- package/assets/packages/cli/src/internal/workflow-registry.ts +281 -70
- package/assets/packages/cli/src/internal/working-directory.ts +2 -1
- package/assets/packages/cli/src/workflow-loader.ts +341 -74
- package/assets/packages/core/src/atomic-files.ts +13 -4
- package/assets/packages/core/src/workflow-transition.ts +13 -2
- package/assets/packages/sdk/src/api.ts +248 -80
- package/assets/packages/sdk/src/index.ts +6 -1
- package/assets/packages/sdk/src/schema.ts +75 -21
- package/assets/tests/workflow-ref.test.ts +242 -57
- package/bin/norn.mjs +13 -4
- package/dist/cli.js +344 -90
- package/dist/client.js +100 -22
- package/dist/documentation.js +102 -25
- package/dist/generated-build-info.d.ts +2 -2
- package/dist/generated-build-info.js +2 -2
- package/dist/internal/agent-directory.js +3 -1
- package/dist/internal/agent-response-tool.js +42 -11
- package/dist/internal/agents.js +149 -37
- package/dist/internal/commands.js +65 -16
- package/dist/internal/documentation-bundle.js +23 -11
- package/dist/internal/engine.js +304 -66
- package/dist/internal/errors.js +4 -2
- package/dist/internal/execution-context.js +2 -1
- package/dist/internal/file-coordinator.js +59 -19
- package/dist/internal/launch-request.js +32 -14
- package/dist/internal/metrics.js +138 -38
- package/dist/internal/pi-assets.js +70 -19
- package/dist/internal/run-lease.js +29 -9
- package/dist/internal/run-log.js +24 -12
- package/dist/internal/run-state.js +172 -48
- package/dist/internal/run-store.js +157 -45
- package/dist/internal/worker-directory.js +6 -2
- package/dist/internal/workflow-registry.js +123 -32
- package/dist/internal/working-directory.js +2 -1
- package/dist/workflow-loader.js +198 -41
- package/package.json +2 -2
|
@@ -6,11 +6,16 @@ export class NornProjectLoadError extends Error {
|
|
|
6
6
|
readonly isComplete = false;
|
|
7
7
|
readonly diagnostics: readonly NornWorkflowDiagnostic[];
|
|
8
8
|
|
|
9
|
-
constructor(input: {
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
constructor(input: {
|
|
10
|
+
readonly diagnostics: readonly NornWorkflowDiagnostic[];
|
|
11
|
+
}) {
|
|
12
|
+
const details = input.diagnostics.map(
|
|
13
|
+
(diagnostic) =>
|
|
14
|
+
`${diagnostic.stage}: ${diagnostic.modulePath} (declared in ${diagnostic.configPath}${diagnostic.workflowId ? `; workflow ${diagnostic.workflowId}` : ""}): ${diagnostic.message}`,
|
|
15
|
+
);
|
|
16
|
+
super(
|
|
17
|
+
`Norn project is incomplete (${input.diagnostics.length} diagnostics); execution is blocked.\n${details.join("\n")}`,
|
|
12
18
|
);
|
|
13
|
-
super(`Norn project is incomplete (${input.diagnostics.length} diagnostics); execution is blocked.\n${details.join("\n")}`);
|
|
14
19
|
this.name = "NornProjectLoadError";
|
|
15
20
|
this.diagnostics = input.diagnostics;
|
|
16
21
|
}
|
|
@@ -24,7 +29,10 @@ export class NornRunStoppedError extends Error {
|
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export function schemaErrorMessage(error: AssertError): string {
|
|
27
|
-
return error.cause.errors
|
|
32
|
+
return error.cause.errors
|
|
33
|
+
.slice(0, 3)
|
|
34
|
+
.map((issue) => `${issue.instancePath || "/"}: ${issue.message}`)
|
|
35
|
+
.join("; ");
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
export function errorMessage(error: unknown): string {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
NornAgents,
|
|
4
|
+
NornCommands,
|
|
5
|
+
NornLogs,
|
|
6
|
+
NornRun,
|
|
7
|
+
} from "@vimhead.dev/norn";
|
|
3
8
|
import { createWorkflowTransition } from "@vimhead.dev/norn-core/workflow-transition";
|
|
4
9
|
import type { NornAgentResponseCollector } from "./agent-response-tool.ts";
|
|
5
10
|
import { NornAgentRunner } from "./agents.ts";
|
|
@@ -28,23 +33,26 @@ export class NornExecutionContext {
|
|
|
28
33
|
constructor(private readonly input: NornExecutionContextInput) {
|
|
29
34
|
this.run = {
|
|
30
35
|
id: input.id,
|
|
31
|
-
next: (workflowId, args) =>
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
next: (workflowId, args) =>
|
|
37
|
+
createWorkflowTransition({ workflowId, args }),
|
|
38
|
+
complete: (metadata) => ({ type: "complete", metadata }),
|
|
39
|
+
fail: (metadata) => ({ type: "fail", metadata }),
|
|
34
40
|
};
|
|
35
41
|
this.logs = {
|
|
36
|
-
read: log => this.input.logs.read(log),
|
|
42
|
+
read: (log) => this.input.logs.read(log),
|
|
37
43
|
};
|
|
38
44
|
this.commands = {
|
|
39
|
-
run: commandInput =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
run: (commandInput) =>
|
|
46
|
+
new NornCommandRunner({
|
|
47
|
+
signal: this.input.signal,
|
|
48
|
+
logs: this.input.logs,
|
|
49
|
+
logger: this.input.logger,
|
|
50
|
+
}).run(commandInput),
|
|
44
51
|
};
|
|
45
52
|
this.agents = {
|
|
46
|
-
createSession: agentInput =>
|
|
47
|
-
|
|
53
|
+
createSession: (agentInput) =>
|
|
54
|
+
this.createAgentRunner().createSession(agentInput),
|
|
55
|
+
prompt: (agentInput) => this.createAgentRunner().prompt(agentInput),
|
|
48
56
|
};
|
|
49
57
|
}
|
|
50
58
|
|
|
@@ -1,29 +1,53 @@
|
|
|
1
1
|
import { writeTextAtomically } from "@vimhead.dev/norn-core/atomic-files";
|
|
2
2
|
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
mkdtemp,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
realpath,
|
|
11
|
+
rename,
|
|
12
|
+
rm,
|
|
13
|
+
rmdir,
|
|
14
|
+
unlink,
|
|
15
|
+
writeFile,
|
|
16
|
+
} from "node:fs/promises";
|
|
5
17
|
import { hostname } from "node:os";
|
|
6
18
|
import { basename, dirname, join, resolve } from "node:path";
|
|
7
19
|
import { setTimeout as delay } from "node:timers/promises";
|
|
8
20
|
import { Type } from "typebox";
|
|
9
21
|
import { Value } from "typebox/value";
|
|
10
22
|
|
|
11
|
-
const ownerSchema = Type.Object(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
const ownerSchema = Type.Object(
|
|
24
|
+
{
|
|
25
|
+
token: Type.String({ format: "uuid" }),
|
|
26
|
+
pid: Type.Integer({ exclusiveMinimum: 0 }),
|
|
27
|
+
host: Type.String(),
|
|
28
|
+
target: Type.String(),
|
|
29
|
+
},
|
|
30
|
+
{ additionalProperties: false },
|
|
31
|
+
);
|
|
17
32
|
|
|
18
33
|
export class NornFileCoordinator {
|
|
19
34
|
private readonly lockRoot: string;
|
|
20
35
|
|
|
21
|
-
constructor(
|
|
22
|
-
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly input: {
|
|
38
|
+
readonly lockRoot: string;
|
|
39
|
+
readonly waitTimeoutMs: number;
|
|
40
|
+
},
|
|
41
|
+
) {
|
|
42
|
+
if (!Number.isSafeInteger(input.waitTimeoutMs) || input.waitTimeoutMs <= 0)
|
|
43
|
+
throw new Error("Lock wait timeout must be a positive integer");
|
|
23
44
|
this.lockRoot = resolve(input.lockRoot);
|
|
24
45
|
}
|
|
25
46
|
|
|
26
|
-
async withExclusiveLock<T>(
|
|
47
|
+
async withExclusiveLock<T>(
|
|
48
|
+
path: string,
|
|
49
|
+
operation: (lockedPath: string) => Promise<T>,
|
|
50
|
+
): Promise<T> {
|
|
27
51
|
const target = await this.resolveTarget(path);
|
|
28
52
|
await mkdir(this.lockRoot, { recursive: true, mode: 0o700 });
|
|
29
53
|
const key = createHash("sha256").update(target).digest("hex");
|
|
@@ -33,7 +57,11 @@ export class NornFileCoordinator {
|
|
|
33
57
|
const marker = `${token}.json`;
|
|
34
58
|
return this.withCleanup({
|
|
35
59
|
operation: async () => {
|
|
36
|
-
await writeFile(
|
|
60
|
+
await writeFile(
|
|
61
|
+
join(stagingPath, marker),
|
|
62
|
+
JSON.stringify({ token, pid: process.pid, host: hostname(), target }),
|
|
63
|
+
{ mode: 0o600 },
|
|
64
|
+
);
|
|
37
65
|
await this.acquire({ stagingPath, lockPath, target });
|
|
38
66
|
return this.withCleanup({
|
|
39
67
|
operation: () => operation(target),
|
|
@@ -48,21 +76,34 @@ export class NornFileCoordinator {
|
|
|
48
76
|
}
|
|
49
77
|
|
|
50
78
|
async readText(path: string): Promise<string> {
|
|
51
|
-
return this.withExclusiveLock(path, (lockedPath) =>
|
|
79
|
+
return this.withExclusiveLock(path, (lockedPath) =>
|
|
80
|
+
readFile(lockedPath, "utf8"),
|
|
81
|
+
);
|
|
52
82
|
}
|
|
53
83
|
|
|
54
84
|
async writeText(path: string, content: string): Promise<void> {
|
|
55
85
|
await mkdir(dirname(path), { recursive: true });
|
|
56
|
-
await this.withExclusiveLock(path, (lockedPath) =>
|
|
86
|
+
await this.withExclusiveLock(path, (lockedPath) =>
|
|
87
|
+
writeTextAtomically(lockedPath, content),
|
|
88
|
+
);
|
|
57
89
|
}
|
|
58
90
|
|
|
59
|
-
private async withCleanup<T>(input: {
|
|
91
|
+
private async withCleanup<T>(input: {
|
|
92
|
+
readonly operation: () => Promise<T>;
|
|
93
|
+
readonly cleanup: () => Promise<void>;
|
|
94
|
+
}): Promise<T> {
|
|
60
95
|
let result: T;
|
|
61
96
|
try {
|
|
62
97
|
result = await input.operation();
|
|
63
98
|
} catch (error) {
|
|
64
|
-
try {
|
|
65
|
-
|
|
99
|
+
try {
|
|
100
|
+
await input.cleanup();
|
|
101
|
+
} catch (cleanupError) {
|
|
102
|
+
throw new AggregateError(
|
|
103
|
+
[error, cleanupError],
|
|
104
|
+
"File operation and lock cleanup failed",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
66
107
|
throw error;
|
|
67
108
|
}
|
|
68
109
|
await input.cleanup();
|
|
@@ -78,34 +119,55 @@ export class NornFileCoordinator {
|
|
|
78
119
|
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
79
120
|
throw error;
|
|
80
121
|
});
|
|
81
|
-
if (entry?.isSymbolicLink())
|
|
122
|
+
if (entry?.isSymbolicLink())
|
|
123
|
+
throw new Error(`Cannot lock a dangling symbolic link: ${path}`);
|
|
82
124
|
return join(await realpath(dirname(path)), basename(path));
|
|
83
125
|
}
|
|
84
126
|
}
|
|
85
127
|
|
|
86
|
-
private async acquire(input: {
|
|
128
|
+
private async acquire(input: {
|
|
129
|
+
readonly stagingPath: string;
|
|
130
|
+
readonly lockPath: string;
|
|
131
|
+
readonly target: string;
|
|
132
|
+
}): Promise<void> {
|
|
87
133
|
const deadline = Date.now() + this.input.waitTimeoutMs;
|
|
88
134
|
while (true) {
|
|
89
135
|
try {
|
|
90
136
|
await rename(input.stagingPath, input.lockPath);
|
|
91
137
|
return;
|
|
92
138
|
} catch (error) {
|
|
93
|
-
if (
|
|
139
|
+
if (
|
|
140
|
+
!isNodeError(error) ||
|
|
141
|
+
!["ENOTEMPTY", "EEXIST"].includes(error.code ?? "")
|
|
142
|
+
)
|
|
143
|
+
throw error;
|
|
94
144
|
}
|
|
95
145
|
await this.reclaimDeadOwner(input);
|
|
96
|
-
if (Date.now() >= deadline)
|
|
146
|
+
if (Date.now() >= deadline)
|
|
147
|
+
throw new Error(`Timed out waiting for file lock: ${input.target}`);
|
|
97
148
|
await delay(10);
|
|
98
149
|
}
|
|
99
150
|
}
|
|
100
151
|
|
|
101
|
-
private async reclaimDeadOwner(input: {
|
|
152
|
+
private async reclaimDeadOwner(input: {
|
|
153
|
+
readonly lockPath: string;
|
|
154
|
+
readonly target: string;
|
|
155
|
+
}): Promise<void> {
|
|
102
156
|
try {
|
|
103
157
|
const entries = await readdir(input.lockPath);
|
|
104
158
|
if (entries.length === 0) return;
|
|
105
|
-
if (entries.length !== 1)
|
|
159
|
+
if (entries.length !== 1)
|
|
160
|
+
throw new Error(`Invalid file lock ownership: ${input.lockPath}`);
|
|
106
161
|
const marker = entries[0];
|
|
107
|
-
const owner = Value.Parse(
|
|
108
|
-
|
|
162
|
+
const owner = Value.Parse(
|
|
163
|
+
ownerSchema,
|
|
164
|
+
JSON.parse(await readFile(join(input.lockPath, marker), "utf8")),
|
|
165
|
+
);
|
|
166
|
+
if (
|
|
167
|
+
marker !== `${owner.token}.json` ||
|
|
168
|
+
owner.target !== input.target ||
|
|
169
|
+
owner.host !== hostname()
|
|
170
|
+
) {
|
|
109
171
|
throw new Error(`Incompatible file lock ownership: ${input.lockPath}`);
|
|
110
172
|
}
|
|
111
173
|
try {
|
|
@@ -127,11 +189,18 @@ export class NornFileCoordinator {
|
|
|
127
189
|
try {
|
|
128
190
|
await rmdir(path);
|
|
129
191
|
} catch (error) {
|
|
130
|
-
if (
|
|
192
|
+
if (
|
|
193
|
+
!isNodeError(error) ||
|
|
194
|
+
!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code ?? "")
|
|
195
|
+
)
|
|
196
|
+
throw error;
|
|
131
197
|
}
|
|
132
198
|
}
|
|
133
199
|
}
|
|
134
200
|
|
|
135
201
|
export function createRunFileCoordinator(runRoot: string): NornFileCoordinator {
|
|
136
|
-
return new NornFileCoordinator({
|
|
202
|
+
return new NornFileCoordinator({
|
|
203
|
+
lockRoot: join(runRoot, "locks"),
|
|
204
|
+
waitTimeoutMs: 30_000,
|
|
205
|
+
});
|
|
137
206
|
}
|
|
@@ -29,15 +29,25 @@ export type NornRunResumeRequest = {
|
|
|
29
29
|
readonly createdAt: string;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
export async function writeRunLaunchRequest(
|
|
32
|
+
export async function writeRunLaunchRequest(
|
|
33
|
+
runRoot: string,
|
|
34
|
+
request: NornRunLaunchRequest,
|
|
35
|
+
): Promise<void> {
|
|
33
36
|
await writeJsonAtomically(join(runRoot, LAUNCH_REQUEST_FILE_NAME), request);
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
export async function readRunLaunchRequest(
|
|
37
|
-
|
|
39
|
+
export async function readRunLaunchRequest(
|
|
40
|
+
runRoot: string,
|
|
41
|
+
): Promise<NornRunLaunchRequest> {
|
|
42
|
+
return parseRunLaunchRequest(
|
|
43
|
+
JSON.parse(await readFile(join(runRoot, LAUNCH_REQUEST_FILE_NAME), "utf8")),
|
|
44
|
+
);
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
export async function writeRunResumeRequest(
|
|
47
|
+
export async function writeRunResumeRequest(
|
|
48
|
+
runRoot: string,
|
|
49
|
+
request: NornRunResumeRequest,
|
|
50
|
+
): Promise<void> {
|
|
41
51
|
const path = join(runRoot, RESUME_REQUEST_FILE_NAME);
|
|
42
52
|
const stagedPath = `${path}.${randomUUID()}.tmp`;
|
|
43
53
|
try {
|
|
@@ -48,11 +58,17 @@ export async function writeRunResumeRequest(runRoot: string, request: NornRunRes
|
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
|
|
51
|
-
export async function readRunResumeRequest(
|
|
52
|
-
|
|
61
|
+
export async function readRunResumeRequest(
|
|
62
|
+
runRoot: string,
|
|
63
|
+
): Promise<NornRunResumeRequest> {
|
|
64
|
+
return parseRunResumeRequest(
|
|
65
|
+
JSON.parse(await readFile(join(runRoot, RESUME_REQUEST_FILE_NAME), "utf8")),
|
|
66
|
+
);
|
|
53
67
|
}
|
|
54
68
|
|
|
55
|
-
export async function readOptionalRunResumeRequest(
|
|
69
|
+
export async function readOptionalRunResumeRequest(
|
|
70
|
+
runRoot: string,
|
|
71
|
+
): Promise<NornRunResumeRequest | undefined> {
|
|
56
72
|
try {
|
|
57
73
|
return await readRunResumeRequest(runRoot);
|
|
58
74
|
} catch (error) {
|
|
@@ -61,34 +77,70 @@ export async function readOptionalRunResumeRequest(runRoot: string): Promise<Nor
|
|
|
61
77
|
}
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
export function matchesRunResumeRequest(
|
|
65
|
-
|
|
80
|
+
export function matchesRunResumeRequest(
|
|
81
|
+
actual: NornRunResumeRequest | undefined,
|
|
82
|
+
expected: NornRunResumeRequest,
|
|
83
|
+
): boolean {
|
|
84
|
+
return (
|
|
85
|
+
actual?.id === expected.id &&
|
|
86
|
+
(actual.requestId ?? actual.createdAt) ===
|
|
87
|
+
(expected.requestId ?? expected.createdAt)
|
|
88
|
+
);
|
|
66
89
|
}
|
|
67
90
|
|
|
68
|
-
export async function clearRunResumeRequest(input: {
|
|
91
|
+
export async function clearRunResumeRequest(input: {
|
|
92
|
+
readonly runRoot: string;
|
|
93
|
+
readonly request: NornRunResumeRequest;
|
|
94
|
+
readonly lease: NornRunLease;
|
|
95
|
+
}): Promise<void> {
|
|
69
96
|
await input.lease.assertOwned();
|
|
70
|
-
if (
|
|
97
|
+
if (
|
|
98
|
+
matchesRunResumeRequest(
|
|
99
|
+
await readOptionalRunResumeRequest(input.runRoot),
|
|
100
|
+
input.request,
|
|
101
|
+
)
|
|
102
|
+
) {
|
|
71
103
|
await rm(join(input.runRoot, RESUME_REQUEST_FILE_NAME), { force: true });
|
|
72
104
|
}
|
|
73
105
|
}
|
|
74
106
|
|
|
75
107
|
function parseRunLaunchRequest(value: unknown): NornRunLaunchRequest {
|
|
76
|
-
if (!value || typeof value !== "object")
|
|
108
|
+
if (!value || typeof value !== "object")
|
|
109
|
+
throw new Error("Invalid workflow launch request");
|
|
77
110
|
const request = value as Partial<NornRunLaunchRequest>;
|
|
78
|
-
if (request.version !== 2 || request.type !== "run")
|
|
79
|
-
|
|
80
|
-
if (typeof request.
|
|
81
|
-
|
|
82
|
-
if (typeof request.
|
|
111
|
+
if (request.version !== 2 || request.type !== "run")
|
|
112
|
+
throw new Error("Unsupported workflow launch request");
|
|
113
|
+
if (typeof request.id !== "string" || request.id.length === 0)
|
|
114
|
+
throw new Error("Invalid workflow launch request id");
|
|
115
|
+
if (typeof request.name !== "string" || request.name.length === 0)
|
|
116
|
+
throw new Error("Invalid workflow launch request name");
|
|
117
|
+
if (typeof request.workflowId !== "string" || request.workflowId.length === 0)
|
|
118
|
+
throw new Error("Invalid workflow launch request workflow id");
|
|
119
|
+
if (
|
|
120
|
+
typeof request.createdAt !== "string" ||
|
|
121
|
+
Number.isNaN(Date.parse(request.createdAt))
|
|
122
|
+
)
|
|
123
|
+
throw new Error("Invalid workflow launch request timestamp");
|
|
83
124
|
return request as NornRunLaunchRequest;
|
|
84
125
|
}
|
|
85
126
|
|
|
86
127
|
function parseRunResumeRequest(value: unknown): NornRunResumeRequest {
|
|
87
|
-
if (!value || typeof value !== "object")
|
|
128
|
+
if (!value || typeof value !== "object")
|
|
129
|
+
throw new Error("Invalid workflow resume request");
|
|
88
130
|
const request = value as Partial<NornRunResumeRequest>;
|
|
89
|
-
if (request.version !== 2 || request.type !== "resume")
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
131
|
+
if (request.version !== 2 || request.type !== "resume")
|
|
132
|
+
throw new Error("Unsupported workflow resume request");
|
|
133
|
+
if (typeof request.id !== "string" || request.id.length === 0)
|
|
134
|
+
throw new Error("Invalid workflow resume request id");
|
|
135
|
+
if (
|
|
136
|
+
request.requestId !== undefined &&
|
|
137
|
+
(typeof request.requestId !== "string" || request.requestId.length === 0)
|
|
138
|
+
)
|
|
139
|
+
throw new Error("Invalid workflow resume request token");
|
|
140
|
+
if (
|
|
141
|
+
typeof request.createdAt !== "string" ||
|
|
142
|
+
Number.isNaN(Date.parse(request.createdAt))
|
|
143
|
+
)
|
|
144
|
+
throw new Error("Invalid workflow resume request timestamp");
|
|
93
145
|
return request as NornRunResumeRequest;
|
|
94
146
|
}
|
|
@@ -10,7 +10,10 @@ export type NornLogWriteStream = {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
export class NornRunLogs {
|
|
13
|
-
constructor(
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly logsRoot: string,
|
|
15
|
+
private readonly files: NornFileCoordinator,
|
|
16
|
+
) {}
|
|
14
17
|
|
|
15
18
|
async write(path: string, content: string): Promise<void> {
|
|
16
19
|
await this.files.writeText(this.resolveLogPath(path), content);
|
|
@@ -33,7 +36,12 @@ export class NornRunLogs {
|
|
|
33
36
|
if (isAbsolute(path)) throw new Error(`Log path must be relative: ${path}`);
|
|
34
37
|
const resolvedPath = resolve(this.logsRoot, path);
|
|
35
38
|
const relativePath = relative(this.logsRoot, resolvedPath);
|
|
36
|
-
if (
|
|
39
|
+
if (
|
|
40
|
+
relativePath === "" ||
|
|
41
|
+
relativePath === ".." ||
|
|
42
|
+
relativePath.startsWith(`..${sep}`) ||
|
|
43
|
+
isAbsolute(relativePath)
|
|
44
|
+
) {
|
|
37
45
|
throw new Error(`Log path escapes logs directory: ${path}`);
|
|
38
46
|
}
|
|
39
47
|
return resolvedPath;
|