opencode-herdr-orchestration 0.1.6 → 0.2.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/src/state.js ADDED
@@ -0,0 +1,336 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fsDefault from "node:fs";
4
+ import pathDefault from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export const ARTIFACT_TYPES = Object.freeze({
10
+ PLAN: "plan",
11
+ EXECUTION: "execution",
12
+ });
13
+
14
+ const ARTIFACT_TYPE_VALUES = new Set(Object.values(ARTIFACT_TYPES));
15
+
16
+ const STATE_DIR = "herdr";
17
+ const ARTIFACT_DIRECTORIES = Object.freeze({
18
+ [ARTIFACT_TYPES.PLAN]: "plans",
19
+ [ARTIFACT_TYPES.EXECUTION]: "executions",
20
+ });
21
+
22
+ const SCHEMA_VERSION = 1;
23
+ const FRONTMATTER_DELIMITER = "---";
24
+
25
+ // Plan IDs become single path segments below the state root. They must never
26
+ // contain separators, traversal sequences, or hidden/relative prefixes.
27
+ const PLAN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
28
+ const METADATA_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
29
+
30
+ const MAX_MARKDOWN_BYTES = 1024 * 1024;
31
+ const MAX_METADATA_VALUE_CHARS = 512;
32
+
33
+ const RESERVED_METADATA_KEYS = Object.freeze([
34
+ "schema",
35
+ "artifactType",
36
+ "planId",
37
+ "identity",
38
+ "toplevel",
39
+ "createdAt",
40
+ "updatedAt",
41
+ ]);
42
+
43
+ function error(code, message, retryable = false) {
44
+ return { ok: false, error: { code, message, retryable } };
45
+ }
46
+
47
+ function processErrorDetail(cause) {
48
+ const value = String(cause?.stderr ?? cause?.message ?? cause).replace(/\s+/g, " ").trim();
49
+ return value.length > 1000 ? `${value.slice(0, 1000)}...` : value;
50
+ }
51
+
52
+ async function defaultRunGit(gitBinary, cwd, args) {
53
+ const { stdout } = await execFileAsync(gitBinary, args, { cwd, encoding: "utf8", windowsHide: true });
54
+ return stdout;
55
+ }
56
+
57
+ function resolveNow(now) {
58
+ if (typeof now === "function") return now();
59
+ if (now instanceof Date) return now;
60
+ if (typeof now === "string" || typeof now === "number") return new Date(now);
61
+ return new Date();
62
+ }
63
+
64
+ function validateArtifactType(type) {
65
+ if (typeof type !== "string" || !ARTIFACT_TYPE_VALUES.has(type)) {
66
+ return error(
67
+ "INVALID_ARTIFACT_TYPE",
68
+ `Artifact type must be one of: ${[...ARTIFACT_TYPE_VALUES].join(", ")}.`,
69
+ );
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function validatePlanId(planId) {
75
+ if (typeof planId !== "string" || !PLAN_ID_PATTERN.test(planId)) {
76
+ return error(
77
+ "INVALID_PLAN_ID",
78
+ "Plan ID must be 1-64 characters of letters, digits, dot, underscore, or hyphen, and must not start with a dot.",
79
+ );
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function validateMarkdown(markdown) {
85
+ if (typeof markdown !== "string" || markdown.length === 0) {
86
+ return error("INVALID_MARKDOWN", "Markdown body must be a non-empty string.");
87
+ }
88
+ if (Buffer.byteLength(markdown, "utf8") > MAX_MARKDOWN_BYTES) {
89
+ return error("INVALID_MARKDOWN", `Markdown body must not exceed ${MAX_MARKDOWN_BYTES} UTF-8 bytes.`);
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function validateMetadata(metadata) {
95
+ if (metadata === undefined) return null;
96
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
97
+ return error("INVALID_METADATA", "Metadata must be an object with string values.");
98
+ }
99
+ for (const [key, value] of Object.entries(metadata)) {
100
+ if (!METADATA_KEY_PATTERN.test(key)) {
101
+ return error("INVALID_METADATA", `Metadata key ${JSON.stringify(key)} is not a valid key.`);
102
+ }
103
+ if (RESERVED_METADATA_KEYS.includes(key)) {
104
+ return error("INVALID_METADATA", `Metadata key ${JSON.stringify(key)} is reserved.`);
105
+ }
106
+ if (typeof value !== "string" || value.length > MAX_METADATA_VALUE_CHARS) {
107
+ return error(
108
+ "INVALID_METADATA",
109
+ `Metadata value for ${JSON.stringify(key)} must be a string of at most ${MAX_METADATA_VALUE_CHARS} characters.`,
110
+ );
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function serializeArtifact(metadata, markdown) {
117
+ const body = markdown.endsWith("\n") ? markdown : `${markdown}\n`;
118
+ return `${FRONTMATTER_DELIMITER}\n${JSON.stringify(metadata, null, 2)}\n${FRONTMATTER_DELIMITER}\n${body}`;
119
+ }
120
+
121
+ function parseArtifact(text) {
122
+ const open = `${FRONTMATTER_DELIMITER}\n`;
123
+ const close = `\n${FRONTMATTER_DELIMITER}\n`;
124
+ if (typeof text !== "string" || !text.startsWith(open)) {
125
+ return error("CORRUPT_ARTIFACT", "Artifact does not start with a frontmatter block.");
126
+ }
127
+ const end = text.indexOf(close, open.length);
128
+ if (end === -1) {
129
+ return error("CORRUPT_ARTIFACT", "Artifact frontmatter block is not terminated.");
130
+ }
131
+ let metadata;
132
+ try {
133
+ metadata = JSON.parse(text.slice(open.length, end));
134
+ } catch (cause) {
135
+ return error("CORRUPT_ARTIFACT", `Artifact frontmatter is not valid JSON: ${processErrorDetail(cause)}`);
136
+ }
137
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
138
+ return error("CORRUPT_ARTIFACT", "Artifact frontmatter must be a JSON object.");
139
+ }
140
+ return { metadata, markdown: text.slice(end + close.length) };
141
+ }
142
+
143
+ export function createStateService(options = {}) {
144
+ const cwd = options.cwd ?? process.cwd();
145
+ const gitBinary = options.gitBinary ?? "git";
146
+ const runGit = options.runGit ?? ((cwd_, args) => defaultRunGit(gitBinary, cwd_, args));
147
+ const now = options.now ?? (() => new Date());
148
+ const fs = options.fs ?? fsDefault;
149
+ const path = options.path ?? pathDefault;
150
+
151
+ let identityCache;
152
+ let toplevelCache;
153
+
154
+ async function resolveRepositoryLayout() {
155
+ if (identityCache && toplevelCache) {
156
+ return { identity: identityCache, toplevel: toplevelCache };
157
+ }
158
+ let commonDir;
159
+ try {
160
+ commonDir = (await runGit(cwd, ["rev-parse", "--git-common-dir"])).trim();
161
+ } catch (cause) {
162
+ return error("GIT_UNAVAILABLE", `Unable to resolve the Git common directory: ${processErrorDetail(cause)}`, true);
163
+ }
164
+ if (typeof commonDir !== "string" || commonDir.length === 0) {
165
+ return error("GIT_UNAVAILABLE", "Git returned an empty common directory.", true);
166
+ }
167
+ let toplevel;
168
+ try {
169
+ toplevel = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
170
+ } catch (cause) {
171
+ return error("GIT_UNAVAILABLE", `Unable to resolve the worktree top level: ${processErrorDetail(cause)}`, true);
172
+ }
173
+ identityCache = canonicalizePath(commonDir);
174
+ toplevelCache = canonicalizePath(toplevel);
175
+ return { identity: identityCache, toplevel: toplevelCache };
176
+ }
177
+
178
+ function canonicalizePath(value) {
179
+ const absolute = path.isAbsolute(value) ? value : path.resolve(cwd, value);
180
+ try {
181
+ // On Windows, the regular realpath binding can preserve an 8.3 alias
182
+ // while the same directory is reached through its long name elsewhere.
183
+ // The native binding gives one identity across linked worktrees.
184
+ const realpath = fs.realpathSync.native ?? fs.realpathSync;
185
+ return realpath(absolute);
186
+ } catch {
187
+ return path.resolve(absolute);
188
+ }
189
+ }
190
+
191
+ function artifactPath(layout, type, planId) {
192
+ const root = path.join(layout.identity, STATE_DIR, ARTIFACT_DIRECTORIES[type]);
193
+ const target = path.join(root, `${planId}.md`);
194
+ const resolvedRoot = path.resolve(root);
195
+ const resolvedTarget = path.resolve(target);
196
+ const relative = path.relative(resolvedRoot, resolvedTarget);
197
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
198
+ return error("PATH_UNSAFE", `Resolved artifact path escapes the ${type} state directory.`);
199
+ }
200
+ return { root: resolvedRoot, target: resolvedTarget };
201
+ }
202
+
203
+ async function writeArtifact(type, { planId, markdown, metadata: extraMetadata }) {
204
+ const typeFailure = validateArtifactType(type);
205
+ if (typeFailure) return typeFailure;
206
+ const planIdFailure = validatePlanId(planId);
207
+ if (planIdFailure) return planIdFailure;
208
+ const markdownFailure = validateMarkdown(markdown);
209
+ if (markdownFailure) return markdownFailure;
210
+ const metadataFailure = validateMetadata(extraMetadata);
211
+ if (metadataFailure) return metadataFailure;
212
+
213
+ const layout = await resolveRepositoryLayout();
214
+ if (layout.error) return layout;
215
+ const location = artifactPath(layout, type, planId);
216
+ if (location.error) return location;
217
+
218
+ const timestamp = resolveNow(now).toISOString();
219
+ let existingMetadata;
220
+ const existing = readStoredArtifact(location.target);
221
+ if (existing.ok) {
222
+ if (existing.artifact.metadata.identity !== layout.identity) {
223
+ return error(
224
+ "IDENTITY_MISMATCH",
225
+ `Existing artifact belongs to repository ${existing.artifact.metadata.identity}, not ${layout.identity}.`,
226
+ );
227
+ }
228
+ existingMetadata = existing.artifact.metadata;
229
+ } else if (existing.error.code !== "NOT_FOUND" && existing.error.code !== "CORRUPT_ARTIFACT") {
230
+ return existing;
231
+ }
232
+
233
+ const metadata = {
234
+ schema: SCHEMA_VERSION,
235
+ artifactType: type,
236
+ planId,
237
+ identity: layout.identity,
238
+ toplevel: layout.toplevel,
239
+ createdAt: existingMetadata?.createdAt ?? timestamp,
240
+ updatedAt: timestamp,
241
+ ...(extraMetadata ?? {}),
242
+ };
243
+
244
+ const tempTarget = `${location.target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
245
+ try {
246
+ fs.mkdirSync(location.root, { recursive: true });
247
+ fs.writeFileSync(tempTarget, serializeArtifact(metadata, markdown), "utf8");
248
+ fs.renameSync(tempTarget, location.target);
249
+ } catch (cause) {
250
+ try {
251
+ fs.unlinkSync(tempTarget);
252
+ } catch {
253
+ // The temp file may not exist when the write itself failed; the
254
+ // rename target is untouched either way.
255
+ }
256
+ return error("WRITE_FAILED", `Unable to atomically write ${type} artifact: ${processErrorDetail(cause)}`, true);
257
+ }
258
+
259
+ return {
260
+ ok: true,
261
+ artifact: { type, planId, path: location.target, metadata },
262
+ };
263
+ }
264
+
265
+ async function readArtifact(type, planId) {
266
+ const typeFailure = validateArtifactType(type);
267
+ if (typeFailure) return typeFailure;
268
+ const planIdFailure = validatePlanId(planId);
269
+ if (planIdFailure) return planIdFailure;
270
+
271
+ const layout = await resolveRepositoryLayout();
272
+ if (layout.error) return layout;
273
+ const location = artifactPath(layout, type, planId);
274
+ if (location.error) return location;
275
+
276
+ const stored = readStoredArtifact(location.target);
277
+ if (stored.error) return stored;
278
+ const { metadata } = stored.artifact;
279
+
280
+ if (metadata.schema !== SCHEMA_VERSION) {
281
+ return error("SCHEMA_MISMATCH", `Artifact schema ${JSON.stringify(metadata.schema)} is not ${SCHEMA_VERSION}.`);
282
+ }
283
+ if (metadata.artifactType !== type) {
284
+ return error("ARTIFACT_TYPE_MISMATCH", `Artifact records type ${JSON.stringify(metadata.artifactType)}, expected ${JSON.stringify(type)}.`);
285
+ }
286
+ if (metadata.planId !== planId) {
287
+ return error("PLAN_ID_MISMATCH", `Artifact records plan ID ${JSON.stringify(metadata.planId)}, expected ${JSON.stringify(planId)}.`);
288
+ }
289
+ if (metadata.identity !== layout.identity) {
290
+ return error(
291
+ "IDENTITY_MISMATCH",
292
+ `Artifact belongs to repository ${metadata.identity}, current repository identity is ${layout.identity}.`,
293
+ );
294
+ }
295
+
296
+ // The worktree top level is provenance only. Linked worktrees sharing a
297
+ // common directory read the same artifacts with different top levels.
298
+ return {
299
+ ok: true,
300
+ artifact: stored.artifact,
301
+ provenance: {
302
+ recordedToplevel: metadata.toplevel,
303
+ currentToplevel: layout.toplevel,
304
+ toplevelMatches: metadata.toplevel === layout.toplevel,
305
+ },
306
+ };
307
+ }
308
+
309
+ function readStoredArtifact(target) {
310
+ let text;
311
+ try {
312
+ text = fs.readFileSync(target, "utf8");
313
+ } catch (cause) {
314
+ if (cause?.code === "ENOENT") {
315
+ return error("NOT_FOUND", `No ${JSON.stringify(target)} artifact exists yet.`, true);
316
+ }
317
+ return error("READ_FAILED", `Unable to read artifact: ${processErrorDetail(cause)}`, true);
318
+ }
319
+ const parsed = parseArtifact(text);
320
+ if (parsed.error) return parsed;
321
+ return {
322
+ ok: true,
323
+ artifact: { metadata: parsed.metadata, markdown: parsed.markdown, path: target },
324
+ };
325
+ }
326
+
327
+ return {
328
+ layout: resolveRepositoryLayout,
329
+ writeArtifact,
330
+ readArtifact,
331
+ writePlan: (input) => writeArtifact(ARTIFACT_TYPES.PLAN, input),
332
+ readPlan: (planId) => readArtifact(ARTIFACT_TYPES.PLAN, planId),
333
+ writeExecution: (input) => writeArtifact(ARTIFACT_TYPES.EXECUTION, input),
334
+ readExecution: (planId) => readArtifact(ARTIFACT_TYPES.EXECUTION, planId),
335
+ };
336
+ }