harnery 0.22.1 → 0.23.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 +1 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +91 -2
- package/dist/core/work/state.d.ts +7 -0
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +112 -23
- package/dist/core/workflow/child-env.d.ts +10 -6
- package/dist/core/workflow/child-env.d.ts.map +1 -1
- package/dist/core/workflow/child-env.js +15 -7
- package/dist/core/workflow/durable-record.d.ts +7 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -0
- package/dist/core/workflow/durable-record.js +83 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +208 -29
- package/dist/core/workflow/index.d.ts +2 -0
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +1 -0
- package/dist/core/workflow/proof.d.ts +4 -0
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +50 -15
- package/dist/core/workflow/run-state.d.ts +3 -0
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +25 -13
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/attestation-error.d.ts +7 -0
- package/dist/core/workflow/workspaces/attestation-error.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/attestation-error.js +25 -0
- package/dist/core/workflow/workspaces/cancellation.d.ts +8 -0
- package/dist/core/workflow/workspaces/cancellation.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/cancellation.js +95 -0
- package/dist/core/workflow/workspaces/cleanup.d.ts +9 -0
- package/dist/core/workflow/workspaces/cleanup.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/cleanup.js +223 -0
- package/dist/core/workflow/workspaces/execution.d.ts +25 -0
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/execution.js +316 -0
- package/dist/core/workflow/workspaces/git.d.ts +25 -0
- package/dist/core/workflow/workspaces/git.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/git.js +102 -0
- package/dist/core/workflow/workspaces/index.d.ts +15 -0
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/index.js +7 -0
- package/dist/core/workflow/workspaces/inspect.d.ts +85 -0
- package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/inspect.js +255 -0
- package/dist/core/workflow/workspaces/integration.d.ts +24 -0
- package/dist/core/workflow/workspaces/integration.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/integration.js +617 -0
- package/dist/core/workflow/workspaces/leases.d.ts +37 -0
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/leases.js +380 -0
- package/dist/core/workflow/workspaces/lifecycle.d.ts +43 -0
- package/dist/core/workflow/workspaces/lifecycle.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/lifecycle.js +622 -0
- package/dist/core/workflow/workspaces/local-git.d.ts +8 -0
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/local-git.js +1474 -0
- package/dist/core/workflow/workspaces/paths.d.ts +23 -0
- package/dist/core/workflow/workspaces/paths.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/paths.js +191 -0
- package/dist/core/workflow/workspaces/recovery.d.ts +3 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/recovery.js +50 -0
- package/dist/core/workflow/workspaces/state.d.ts +60 -0
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/state.js +296 -0
- package/dist/core/workflow/workspaces/types.d.ts +445 -0
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/types.js +2 -0
- package/dist/core/workflow/workspaces/validate.d.ts +14 -0
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/validate.js +476 -0
- package/package.json +5 -3
- package/src/commands/workflow.ts +110 -4
- package/src/core/work/state.ts +156 -28
- package/src/core/workflow/child-env.ts +18 -7
- package/src/core/workflow/durable-record.ts +94 -0
- package/src/core/workflow/engine.ts +230 -30
- package/src/core/workflow/index.ts +73 -0
- package/src/core/workflow/proof.ts +70 -22
- package/src/core/workflow/run-state.ts +46 -11
- package/src/core/workflow/types.ts +14 -0
- package/src/core/workflow/workspaces/attestation-error.ts +30 -0
- package/src/core/workflow/workspaces/cancellation.ts +137 -0
- package/src/core/workflow/workspaces/cleanup.ts +308 -0
- package/src/core/workflow/workspaces/execution.ts +412 -0
- package/src/core/workflow/workspaces/git.ts +125 -0
- package/src/core/workflow/workspaces/index.ts +85 -0
- package/src/core/workflow/workspaces/inspect.ts +421 -0
- package/src/core/workflow/workspaces/integration.ts +829 -0
- package/src/core/workflow/workspaces/leases.ts +495 -0
- package/src/core/workflow/workspaces/lifecycle.ts +792 -0
- package/src/core/workflow/workspaces/local-git.ts +1892 -0
- package/src/core/workflow/workspaces/paths.ts +247 -0
- package/src/core/workflow/workspaces/recovery.ts +56 -0
- package/src/core/workflow/workspaces/state.ts +481 -0
- package/src/core/workflow/workspaces/types.ts +517 -0
- package/src/core/workflow/workspaces/validate.ts +595 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
12
|
+
import type { FilesystemIdentity, ValidatedFilesystemRoot } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
const WINDOWS_AMBIGUOUS = /[<>:"|?*]|[ .]$/;
|
|
15
|
+
|
|
16
|
+
export type ValidatedRoot = ValidatedFilesystemRoot;
|
|
17
|
+
|
|
18
|
+
export interface OpenContainedDirectory {
|
|
19
|
+
path: string;
|
|
20
|
+
descriptor_path: string;
|
|
21
|
+
fd: number;
|
|
22
|
+
identity: FilesystemIdentity;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function filesystemIdentity(path: string): FilesystemIdentity {
|
|
27
|
+
const stat = statSync(path, { bigint: true });
|
|
28
|
+
if (stat.dev < 0n || stat.ino <= 0n) {
|
|
29
|
+
throw new Error(`filesystem identity is unavailable for ${path}`);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
platform: process.platform,
|
|
33
|
+
device: stat.dev.toString(),
|
|
34
|
+
inode: stat.ino.toString(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sameFilesystemIdentity(
|
|
39
|
+
left: FilesystemIdentity,
|
|
40
|
+
right: FilesystemIdentity,
|
|
41
|
+
): boolean {
|
|
42
|
+
return (
|
|
43
|
+
left.platform === right.platform && left.device === right.device && left.inode === right.inode
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function validateConfiguredRoot(root: string): ValidatedRoot {
|
|
48
|
+
assertAbsoluteUnambiguous(root, "writable root");
|
|
49
|
+
if (!existsSync(root)) throw new Error(`writable root does not exist: ${root}`);
|
|
50
|
+
if (lstatSync(root).isSymbolicLink())
|
|
51
|
+
throw new Error(`writable root must not be a symlink: ${root}`);
|
|
52
|
+
if (!lstatSync(root).isDirectory()) throw new Error(`writable root is not a directory: ${root}`);
|
|
53
|
+
const realpath = realpathSync(root);
|
|
54
|
+
if (realpath !== root) {
|
|
55
|
+
throw new Error(`writable root must not contain symlink components: ${root}`);
|
|
56
|
+
}
|
|
57
|
+
return { configured: root, realpath, identity: filesystemIdentity(realpath) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function revalidateRoot(root: ValidatedRoot): void {
|
|
61
|
+
const current = validateConfiguredRoot(root.configured);
|
|
62
|
+
if (
|
|
63
|
+
current.realpath !== root.realpath ||
|
|
64
|
+
!sameFilesystemIdentity(current.identity, root.identity)
|
|
65
|
+
) {
|
|
66
|
+
throw new Error(`writable root identity changed: ${root.configured}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function assertSafeRelativeSegment(value: string, field: string): void {
|
|
71
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${field} must be set`);
|
|
72
|
+
if (containsControl(value)) throw new Error(`${field} contains control characters`);
|
|
73
|
+
if (value !== value.normalize("NFC"))
|
|
74
|
+
throw new Error(`${field} has ambiguous Unicode normalization`);
|
|
75
|
+
if (
|
|
76
|
+
isAbsolute(value) ||
|
|
77
|
+
value === "." ||
|
|
78
|
+
value === ".." ||
|
|
79
|
+
value.includes("/") ||
|
|
80
|
+
value.includes("\\")
|
|
81
|
+
) {
|
|
82
|
+
throw new Error(`${field} must be one safe relative segment`);
|
|
83
|
+
}
|
|
84
|
+
if (process.platform === "win32" && WINDOWS_AMBIGUOUS.test(value)) {
|
|
85
|
+
throw new Error(`${field} is ambiguous on this platform`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function containsPath(root: string, candidate: string): boolean {
|
|
90
|
+
const rel = relative(resolve(root), resolve(candidate));
|
|
91
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${separator()}`) && !isAbsolute(rel));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function assertContainedExisting(
|
|
95
|
+
root: ValidatedRoot,
|
|
96
|
+
candidate: string,
|
|
97
|
+
label: string,
|
|
98
|
+
): string {
|
|
99
|
+
revalidateRoot(root);
|
|
100
|
+
assertAbsoluteUnambiguous(candidate, label);
|
|
101
|
+
if (!existsSync(candidate)) throw new Error(`${label} does not exist: ${candidate}`);
|
|
102
|
+
if (lstatSync(candidate).isSymbolicLink()) throw new Error(`${label} must not be a symlink`);
|
|
103
|
+
const real = realpathSync(candidate);
|
|
104
|
+
if (!containsPath(root.realpath, real)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${label} ${JSON.stringify(candidate)} escapes ${JSON.stringify(root.configured)}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return real;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createContainedDirectories(
|
|
113
|
+
root: ValidatedRoot,
|
|
114
|
+
segments: readonly string[],
|
|
115
|
+
): string {
|
|
116
|
+
const opened = openContainedDirectory(root, segments);
|
|
117
|
+
try {
|
|
118
|
+
return opened.path;
|
|
119
|
+
} finally {
|
|
120
|
+
opened.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function openContainedDirectory(
|
|
125
|
+
root: ValidatedRoot,
|
|
126
|
+
segments: readonly string[],
|
|
127
|
+
): OpenContainedDirectory {
|
|
128
|
+
if (segments.length === 0) throw new Error("contained directory requires at least one segment");
|
|
129
|
+
if (!descriptorBackedPathsSupported()) {
|
|
130
|
+
throw new Error("descriptor-backed workspace paths are unavailable");
|
|
131
|
+
}
|
|
132
|
+
const fds: number[] = [];
|
|
133
|
+
try {
|
|
134
|
+
revalidateRoot(root);
|
|
135
|
+
let cursor = root.realpath;
|
|
136
|
+
let parentFd = openSync(
|
|
137
|
+
cursor,
|
|
138
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
139
|
+
);
|
|
140
|
+
fds.push(parentFd);
|
|
141
|
+
if (!sameFilesystemIdentity(filesystemIdentity(descriptorPath(parentFd)), root.identity)) {
|
|
142
|
+
throw new Error("writable root descriptor identity changed");
|
|
143
|
+
}
|
|
144
|
+
for (const [index, segment] of segments.entries()) {
|
|
145
|
+
assertSafeRelativeSegment(segment, `path segment ${index + 1}`);
|
|
146
|
+
const candidate = join(descriptorPath(parentFd), segment);
|
|
147
|
+
if (existsSync(candidate)) {
|
|
148
|
+
const stat = lstatSync(candidate);
|
|
149
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`contained path component is not an owned directory: ${join(cursor, segment)}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
mkdirSync(candidate, { mode: 0o700 });
|
|
156
|
+
}
|
|
157
|
+
const childFd = openSync(
|
|
158
|
+
candidate,
|
|
159
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
160
|
+
);
|
|
161
|
+
fds.push(childFd);
|
|
162
|
+
const real = realpathSync(descriptorPath(childFd));
|
|
163
|
+
if (!containsPath(root.realpath, real)) {
|
|
164
|
+
throw new Error(`contained path escaped root: ${join(cursor, segment)}`);
|
|
165
|
+
}
|
|
166
|
+
cursor = real;
|
|
167
|
+
parentFd = childFd;
|
|
168
|
+
}
|
|
169
|
+
revalidateRoot(root);
|
|
170
|
+
let closed = false;
|
|
171
|
+
return {
|
|
172
|
+
path: cursor,
|
|
173
|
+
descriptor_path: descriptorPath(parentFd),
|
|
174
|
+
fd: parentFd,
|
|
175
|
+
identity: filesystemIdentity(descriptorPath(parentFd)),
|
|
176
|
+
close() {
|
|
177
|
+
if (closed) return;
|
|
178
|
+
for (const fd of [...fds].reverse()) closeSync(fd);
|
|
179
|
+
closed = true;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
for (const fd of [...fds].reverse()) closeSync(fd);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function candidateUnderRoot(root: ValidatedRoot, segments: readonly string[]): string {
|
|
189
|
+
revalidateRoot(root);
|
|
190
|
+
for (const [index, segment] of segments.entries()) {
|
|
191
|
+
assertSafeRelativeSegment(segment, `path segment ${index + 1}`);
|
|
192
|
+
}
|
|
193
|
+
const candidate = join(root.realpath, ...segments);
|
|
194
|
+
if (!containsPath(root.realpath, candidate)) throw new Error("workspace candidate escapes root");
|
|
195
|
+
return candidate;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function assertPathIdentity(
|
|
199
|
+
path: string,
|
|
200
|
+
expected: FilesystemIdentity,
|
|
201
|
+
label: string,
|
|
202
|
+
): void {
|
|
203
|
+
if (!existsSync(path) || lstatSync(path).isSymbolicLink()) {
|
|
204
|
+
throw new Error(`${label} is missing or is a symlink`);
|
|
205
|
+
}
|
|
206
|
+
if (!sameFilesystemIdentity(filesystemIdentity(path), expected)) {
|
|
207
|
+
throw new Error(`${label} identity changed`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function assertAbsoluteUnambiguous(value: string, field: string): void {
|
|
212
|
+
if (!value || containsControl(value)) throw new Error(`${field} contains control characters`);
|
|
213
|
+
if (!isAbsolute(value)) throw new Error(`${field} must be an absolute path`);
|
|
214
|
+
if (value !== value.normalize("NFC")) {
|
|
215
|
+
throw new Error(`${field} has ambiguous Unicode normalization`);
|
|
216
|
+
}
|
|
217
|
+
if (resolve(value) !== value) {
|
|
218
|
+
throw new Error(`${field} contains parent traversal or ambiguous path normalization`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function separator(): string {
|
|
223
|
+
return process.platform === "win32" ? "\\" : "/";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function containsControl(value: string): boolean {
|
|
227
|
+
return Array.from(value).some((character) => {
|
|
228
|
+
const code = character.charCodeAt(0);
|
|
229
|
+
return code < 32 || code === 127;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function descriptorBackedPathsSupported(): boolean {
|
|
234
|
+
return (
|
|
235
|
+
process.platform !== "win32" &&
|
|
236
|
+
constants.O_DIRECTORY !== undefined &&
|
|
237
|
+
constants.O_NOFOLLOW !== undefined &&
|
|
238
|
+
existsSync("/proc/self/fd")
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function descriptorPath(fd: number): string {
|
|
243
|
+
if (!descriptorBackedPathsSupported() || !Number.isSafeInteger(fd) || fd < 0) {
|
|
244
|
+
throw new Error("descriptor-backed workspace paths are unavailable");
|
|
245
|
+
}
|
|
246
|
+
return `/proc/self/fd/${fd}`;
|
|
247
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { readWorkflowProof } from "../proof.ts";
|
|
4
|
+
|
|
5
|
+
export type WorkflowLiveness = "active" | "inactive" | "unknown";
|
|
6
|
+
|
|
7
|
+
export function readWorkflowLiveness(coordRoot: string, runId: string): WorkflowLiveness {
|
|
8
|
+
const root = resolve(coordRoot);
|
|
9
|
+
const proofPath = join(root, ".harnery", "workflows", runId, "proof.json");
|
|
10
|
+
if (existsSync(proofPath)) {
|
|
11
|
+
try {
|
|
12
|
+
const proof = readWorkflowProof(root, runId);
|
|
13
|
+
return ["succeeded", "failed"].includes(proof.run.status) ? "inactive" : "unknown";
|
|
14
|
+
} catch {
|
|
15
|
+
return "unknown";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const journalPath = join(root, ".harnery", "workflows", runId, "journal.jsonl");
|
|
20
|
+
if (!existsSync(journalPath)) return "unknown";
|
|
21
|
+
let events: Array<{ event?: string; run_id?: string; ok?: unknown; status?: unknown }>;
|
|
22
|
+
try {
|
|
23
|
+
events = readFileSync(journalPath, "utf8")
|
|
24
|
+
.split("\n")
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map(
|
|
27
|
+
(line) =>
|
|
28
|
+
JSON.parse(line) as {
|
|
29
|
+
event?: string;
|
|
30
|
+
run_id?: string;
|
|
31
|
+
ok?: unknown;
|
|
32
|
+
status?: unknown;
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
} catch {
|
|
36
|
+
return "unknown";
|
|
37
|
+
}
|
|
38
|
+
if (events.some((item) => item.run_id !== runId || typeof item.event !== "string")) {
|
|
39
|
+
return "unknown";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let liveness: WorkflowLiveness | undefined;
|
|
43
|
+
for (const item of events) {
|
|
44
|
+
if (item.event === "run.start" || item.event === "run.resume") liveness = "active";
|
|
45
|
+
if (item.event === "run.parked") liveness = "inactive";
|
|
46
|
+
if (item.event === "run.end") {
|
|
47
|
+
if (typeof item.ok !== "boolean") return "unknown";
|
|
48
|
+
liveness = "inactive";
|
|
49
|
+
}
|
|
50
|
+
if (item.event === "workspace.reattach.failed") {
|
|
51
|
+
if (item.status !== "blocked" && item.status !== "lost") return "unknown";
|
|
52
|
+
liveness = "inactive";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return liveness ?? "unknown";
|
|
56
|
+
}
|