@syntax-syllogism/aloop 0.5.3 → 0.6.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.mjs CHANGED
@@ -1,5 +1,129 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { access, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { readdirSync, renameSync, rmSync } from 'node:fs';
2
4
  import { join } from 'node:path';
5
+ import { MANIFEST_VERSION, Manifest } from './manifest.mjs';
6
+
7
+ export const STATE_SCHEMA_VERSION = 1;
8
+
9
+ function newRunId() {
10
+ return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
11
+ }
12
+
13
+ function isProcessAlive(pid) {
14
+ if (!Number.isInteger(pid) || pid <= 0) return false;
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ } catch (error) {
19
+ return error.code === 'EPERM';
20
+ }
21
+ }
22
+
23
+ function isProcessGroupAlive(groupId) {
24
+ if (process.platform === 'win32' || !Number.isInteger(groupId) || groupId <= 0 || groupId === process.pid) return false;
25
+ try {
26
+ process.kill(-groupId, 0);
27
+ return true;
28
+ } catch (error) {
29
+ return error.code === 'EPERM';
30
+ }
31
+ }
32
+
33
+ function isActiveProcessAlive(activeProcess) {
34
+ return isProcessGroupAlive(activeProcess?.processGroupId) || isProcessAlive(activeProcess?.pid);
35
+ }
36
+
37
+ async function readActiveProcess(dir) {
38
+ try {
39
+ return JSON.parse(await readFile(join(dir, 'active-command.json'), 'utf8'));
40
+ } catch (error) {
41
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return null;
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ function lockOwnerName(lockToken) {
47
+ return `owner-${lockToken.pid}-${lockToken.token}`;
48
+ }
49
+
50
+ function lockOwnerPath(lockPath, lockToken) {
51
+ return join(lockPath, lockOwnerName(lockToken));
52
+ }
53
+
54
+ function parseLockOwner(name) {
55
+ const match = /^owner-(\d+)-([0-9a-f-]+)$/.exec(name);
56
+ if (!match) return null;
57
+ return { pid: Number(match[1]), token: match[2] };
58
+ }
59
+
60
+ function unreadableLockError(path) {
61
+ return new Error(`Run already active; lock directory "${path}" is unreadable or stale.`);
62
+ }
63
+
64
+ async function readLockOwner(lockPath) {
65
+ let entries;
66
+ try {
67
+ entries = await readdir(lockPath);
68
+ } catch (error) {
69
+ if (error.code === 'ENOENT') throw error;
70
+ throw unreadableLockError(lockPath);
71
+ }
72
+
73
+ const owners = entries.filter((entry) => parseLockOwner(entry));
74
+ const temporaryEntries = entries.filter((entry) => entry.endsWith('.tmp'));
75
+ if (entries.length === 0) return null;
76
+ if (owners.length !== 1 || owners.length + temporaryEntries.length !== entries.length) {
77
+ throw unreadableLockError(lockPath);
78
+ }
79
+
80
+ const owner = parseLockOwner(owners[0]);
81
+ try {
82
+ JSON.parse(await readFile(join(lockPath, owners[0]), 'utf8'));
83
+ } catch (error) {
84
+ if (error.code === 'ENOENT') throw error;
85
+ throw unreadableLockError(lockPath);
86
+ }
87
+ return { ...owner, path: join(lockPath, owners[0]) };
88
+ }
89
+
90
+ function validateSchema(data, source) {
91
+ const version = Object.hasOwn(data, 'schemaVersion') ? data.schemaVersion : 0;
92
+ if (!Number.isInteger(version)) {
93
+ throw new Error(
94
+ `Unsupported ${source} schemaVersion ${version}; expected an integer version.`,
95
+ );
96
+ }
97
+ if (version > STATE_SCHEMA_VERSION) {
98
+ throw new Error(
99
+ `Unsupported ${source} schemaVersion ${version}; this aloop version supports ${STATE_SCHEMA_VERSION}.`,
100
+ );
101
+ }
102
+ if (version < STATE_SCHEMA_VERSION && version !== 0) {
103
+ throw new Error(
104
+ `Unsupported ${source} schemaVersion ${version}; no migration is available to ${STATE_SCHEMA_VERSION}.`,
105
+ );
106
+ }
107
+ return version === 0 ? { ...data, schemaVersion: STATE_SCHEMA_VERSION } : data;
108
+ }
109
+
110
+ async function atomicWrite(path, contents) {
111
+ const tempPath = `${path}.tmp`;
112
+ await writeFile(tempPath, contents, 'utf8');
113
+ await rename(tempPath, path);
114
+ }
115
+
116
+ async function publishLock(lockPath, lockToken, contents) {
117
+ const temporaryPath = `${lockPath}.tmp-${lockToken.token}`;
118
+ try {
119
+ await mkdir(temporaryPath);
120
+ await writeFile(lockOwnerPath(temporaryPath, lockToken), contents, { encoding: 'utf8', flag: 'wx' });
121
+ await rename(temporaryPath, lockPath);
122
+ } catch (error) {
123
+ await rm(temporaryPath, { recursive: true, force: true }).catch(() => {});
124
+ throw error;
125
+ }
126
+ }
3
127
 
4
128
  /**
5
129
  * Run state, persisted after every phase.
@@ -21,36 +145,207 @@ export function slugFor(input) {
21
145
  }
22
146
 
23
147
  export class RunState {
24
- constructor(dir, data, readOnly = false) {
148
+ constructor(dir, data, readOnly = false, manifest = new Manifest(), lockToken = null, manifestMetadata = {}) {
25
149
  this.dir = dir;
26
150
  this.data = data;
27
151
  this.readOnly = readOnly;
152
+ this.manifest = manifest;
153
+ this.manifestMetadata = manifestMetadata;
154
+ this.lockToken = lockToken;
155
+ this.exitHandler = null;
28
156
  }
29
157
 
30
- static async open(runsDir, slug, seed, { readOnly = false } = {}) {
158
+ static async open(
159
+ runsDir,
160
+ slug,
161
+ seed,
162
+ { readOnly = false, create = !readOnly, resume = false, lock = !readOnly } = {},
163
+ ) {
31
164
  const dir = join(runsDir, slug);
32
- await mkdir(dir, { recursive: true });
33
- const path = join(dir, 'state.json');
34
- let data;
165
+ const freshData = {
166
+ runId: seed.runId ?? newRunId(),
167
+ startedAt: new Date().toISOString(),
168
+ completed: [],
169
+ rounds: {},
170
+ reviewedShas: {},
171
+ ...seed,
172
+ schemaVersion: STATE_SCHEMA_VERSION,
173
+ };
174
+ let lockToken = null;
35
175
  try {
36
- data = JSON.parse(await readFile(path, 'utf8'));
176
+ if (create) await mkdir(dir, { recursive: true });
177
+ if (lock && create) lockToken = await RunState.acquireLock(dir, freshData.runId);
178
+
179
+ let data = freshData;
180
+ if (create) {
181
+ const path = join(dir, 'state.json');
182
+ let hasExistingRun = false;
183
+ try {
184
+ const persisted = validateSchema(JSON.parse(await readFile(path, 'utf8')), 'state');
185
+ data = {
186
+ ...persisted,
187
+ runId: persisted.runId ?? freshData.runId,
188
+ schemaVersion: STATE_SCHEMA_VERSION,
189
+ };
190
+ hasExistingRun = true;
191
+ } catch (error) {
192
+ if (error.code !== 'ENOENT') throw error;
193
+ }
194
+
195
+ const manifestPath = join(dir, 'manifest.json');
196
+ try {
197
+ validateSchema(JSON.parse(await readFile(manifestPath, 'utf8')), 'manifest');
198
+ hasExistingRun = true;
199
+ } catch (error) {
200
+ if (error.code !== 'ENOENT') throw error;
201
+ }
202
+ if (hasExistingRun && !resume) {
203
+ throw new Error(
204
+ `Run directory "${dir}" already contains a run; use --resume to continue it or choose a new --name.`,
205
+ );
206
+ }
207
+ }
208
+ let manifest = new Manifest();
209
+ let manifestMetadata = {};
210
+ if (create) {
211
+ try {
212
+ const saved = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
213
+ manifest = new Manifest(saved.phases ?? []);
214
+ manifestMetadata = saved;
215
+ } catch (error) {
216
+ if (error.code !== 'ENOENT') throw error;
217
+ }
218
+ }
219
+ const state = new RunState(dir, data, readOnly, manifest, lockToken, manifestMetadata);
220
+ if (lockToken) state.installExitHandler();
221
+ return state;
222
+ } catch (error) {
223
+ if (lockToken) await RunState.releaseLock(dir, lockToken);
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ static async acquireLock(dir, runId) {
229
+ const path = join(dir, 'lock');
230
+ const lockToken = { pid: process.pid, startedAt: new Date().toISOString(), runId, token: randomUUID() };
231
+ const contents = `${JSON.stringify(lockToken, null, 2)}\n`;
232
+ while (true) {
233
+ try {
234
+ await publishLock(path, lockToken, contents);
235
+ return lockToken;
236
+ } catch (error) {
237
+ if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error;
238
+ }
239
+
240
+ let existing;
241
+ try {
242
+ existing = await readLockOwner(path);
243
+ } catch (readError) {
244
+ if (readError.code === 'ENOENT') continue;
245
+ throw readError;
246
+ }
247
+ if (!existing) {
248
+ const abandonedPath = `${path}.abandoned-${lockToken.token}`;
249
+ try {
250
+ await rename(path, abandonedPath);
251
+ } catch (renameError) {
252
+ if (renameError.code === 'ENOENT') continue;
253
+ throw renameError;
254
+ }
255
+ await rm(abandonedPath, { recursive: true, force: true });
256
+ continue;
257
+ }
258
+ if (isProcessAlive(existing.pid)) {
259
+ throw new Error(
260
+ `Run already active for "${dir}" (pid ${existing.pid}); wait for it to finish or remove a stale lock after confirming the process is gone.`,
261
+ );
262
+ }
263
+
264
+ const activeProcess = await readActiveProcess(dir);
265
+ if (isActiveProcessAlive(activeProcess)) {
266
+ throw new Error(
267
+ `Run still has an active command for "${dir}" (pid ${activeProcess.pid}); wait for it to finish before resuming.`,
268
+ );
269
+ }
270
+
271
+ try {
272
+ await rename(existing.path, lockOwnerPath(path, lockToken));
273
+ } catch (renameError) {
274
+ if (renameError.code === 'ENOENT') continue;
275
+ throw renameError;
276
+ }
277
+ try {
278
+ await atomicWrite(lockOwnerPath(path, lockToken), contents);
279
+ return lockToken;
280
+ } catch (writeError) {
281
+ await RunState.releaseLock(dir, lockToken);
282
+ throw writeError;
283
+ }
284
+ }
285
+ }
286
+
287
+ static async releaseLock(dir, lockToken) {
288
+ const path = join(dir, 'lock');
289
+ const ownerPath = lockOwnerPath(path, lockToken);
290
+ const releasedPath = `${path}.released-${lockToken.token}`;
291
+ try {
292
+ const current = await readLockOwner(path);
293
+ if (!current || current.path !== ownerPath) return;
294
+ await rename(path, releasedPath);
37
295
  } catch (error) {
38
296
  if (error.code !== 'ENOENT') throw error;
39
- data = {
40
- startedAt: new Date().toISOString(),
41
- completed: [],
42
- rounds: {},
43
- reviewedShas: {},
44
- ...seed,
45
- };
297
+ return;
46
298
  }
47
- return new RunState(dir, data, readOnly);
299
+ await rm(releasedPath, { recursive: true, force: true });
300
+ }
301
+
302
+ installExitHandler() {
303
+ this.exitHandler = () => {
304
+ try {
305
+ if (!readdirSync(this.lockPath).includes(lockOwnerName(this.lockToken))) return;
306
+ const releasedPath = `${this.lockPath}.released-${this.lockToken.token}`;
307
+ renameSync(this.lockPath, releasedPath);
308
+ rmSync(releasedPath, { recursive: true, force: true });
309
+ } catch (error) {
310
+ if (error.code !== 'ENOENT') {
311
+ // The process is exiting; there is no useful recovery path here.
312
+ }
313
+ }
314
+ };
315
+ process.once('exit', this.exitHandler);
48
316
  }
49
317
 
50
318
  get path() {
51
319
  return join(this.dir, 'state.json');
52
320
  }
53
321
 
322
+ get lockPath() {
323
+ return join(this.dir, 'lock');
324
+ }
325
+
326
+ get manifestPath() {
327
+ return join(this.dir, 'manifest.json');
328
+ }
329
+
330
+ get snapshotPath() {
331
+ return join(this.dir, 'snapshot.json');
332
+ }
333
+
334
+ async hasSnapshot() {
335
+ try {
336
+ await access(this.snapshotPath);
337
+ return true;
338
+ } catch (error) {
339
+ if (error.code === 'ENOENT') return false;
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ async saveSnapshot(snapshot) {
345
+ if (this.readOnly || await this.hasSnapshot()) return;
346
+ await atomicWrite(this.snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`);
347
+ }
348
+
54
349
  logPath(name) {
55
350
  return join(this.dir, `${name}.log`);
56
351
  }
@@ -74,6 +369,22 @@ export class RunState {
74
369
  await this.save();
75
370
  }
76
371
 
372
+ async saveManifest(manifest) {
373
+ if (this.readOnly) return;
374
+ this.manifestMetadata = { ...this.manifestMetadata, ...manifest };
375
+ await this.persistManifest();
376
+ }
377
+
378
+ async persistManifest() {
379
+ const data = {
380
+ ...this.manifestMetadata,
381
+ manifestVersion: MANIFEST_VERSION,
382
+ schemaVersion: STATE_SCHEMA_VERSION,
383
+ phases: this.manifest.entries,
384
+ };
385
+ await atomicWrite(this.manifestPath, `${JSON.stringify(data, null, 2)}\n`);
386
+ }
387
+
77
388
  /**
78
389
  * Persist, unless this is a dry run.
79
390
  *
@@ -82,7 +393,18 @@ export class RunState {
82
393
  */
83
394
  async save() {
84
395
  if (this.readOnly) return;
396
+ this.data.schemaVersion = STATE_SCHEMA_VERSION;
397
+ if (!this.data.runId) this.data.runId = newRunId();
85
398
  this.data.updatedAt = new Date().toISOString();
86
- await writeFile(this.path, `${JSON.stringify(this.data, null, 2)}\n`);
399
+ await atomicWrite(this.path, `${JSON.stringify(this.data, null, 2)}\n`);
400
+ await this.persistManifest();
401
+ }
402
+
403
+ async release() {
404
+ if (!this.lockToken) return;
405
+ if (this.exitHandler) process.removeListener('exit', this.exitHandler);
406
+ const lockToken = this.lockToken;
407
+ this.lockToken = null;
408
+ await RunState.releaseLock(this.dir, lockToken);
87
409
  }
88
410
  }
package/src/verdict.mjs CHANGED
@@ -19,6 +19,34 @@ function extractJson(text) {
19
19
  }
20
20
  }
21
21
 
22
+ function validateFindings(list, label) {
23
+ list.forEach((finding, index) => {
24
+ const prefix = `${label}[${index}] is malformed:`;
25
+ if (!finding || typeof finding !== 'object' || Array.isArray(finding)) {
26
+ throw new Error(`${prefix} expected an object.`);
27
+ }
28
+
29
+ const description = finding.issue ?? finding.summary;
30
+ if (typeof description !== 'string' || !description.trim()) {
31
+ throw new Error(`${prefix} expected a non-empty issue or summary string.`);
32
+ }
33
+ if ('file' in finding && typeof finding.file !== 'string') {
34
+ throw new Error(`${prefix} file must be a string.`);
35
+ }
36
+ if ('line' in finding && typeof finding.line !== 'number') {
37
+ throw new Error(`${prefix} line must be a number.`);
38
+ }
39
+ });
40
+ }
41
+
42
+ function parseFindingList(value, label) {
43
+ if (value === undefined) return [];
44
+ if (!Array.isArray(value)) {
45
+ throw new Error(`${label} must be an array.`);
46
+ }
47
+ return value;
48
+ }
49
+
22
50
  /**
23
51
  * Validate a verdict.
24
52
  *
@@ -33,14 +61,20 @@ export function parseVerdict(text) {
33
61
  if (![APPROVED, CHANGES_REQUESTED].includes(verdict)) {
34
62
  throw new Error(`Verdict must be ${APPROVED} or ${CHANGES_REQUESTED}, got ${JSON.stringify(data.verdict)}.`);
35
63
  }
36
- const blocking = Array.isArray(data.blocking) ? data.blocking : [];
64
+ const blocking = parseFindingList(data.blocking, 'blocking');
65
+ const nits = parseFindingList(data.nits, 'nits');
66
+ validateFindings(blocking, 'blocking');
67
+ validateFindings(nits, 'nits');
37
68
  if (verdict === CHANGES_REQUESTED && blocking.length === 0) {
38
69
  throw new Error(`Verdict is ${CHANGES_REQUESTED} but lists no blocking findings.`);
39
70
  }
71
+ if (verdict === APPROVED && blocking.length > 0) {
72
+ throw new Error(`Verdict is ${APPROVED} but lists ${blocking.length} blocking findings.`);
73
+ }
40
74
  return {
41
75
  verdict,
42
76
  blocking,
43
- nits: Array.isArray(data.nits) ? data.nits : [],
77
+ nits,
44
78
  summary: typeof data.summary === 'string' ? data.summary : '',
45
79
  };
46
80
  }
@@ -0,0 +1,33 @@
1
+ import { mkdir, stat } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ async function directoryExists(path) {
5
+ try { return (await stat(path)).isDirectory(); } catch { return false; }
6
+ }
7
+
8
+ export async function resumedWorktree(git, repoRoot, branch, savedPath) {
9
+ if (!savedPath) return null;
10
+ if (resolve(savedPath) === resolve(repoRoot)) {
11
+ if (await directoryExists(savedPath)) return savedPath;
12
+ } else {
13
+ const registeredPath = await git.worktreePath(branch);
14
+ if (registeredPath && await directoryExists(registeredPath)) return registeredPath;
15
+ }
16
+ throw new Error(`Saved worktree "${savedPath}" for branch "${branch}" is unavailable. Restore it or re-register it with git worktree before resuming.`);
17
+ }
18
+
19
+ export async function planWorktree(git, config, slug, branch, repoRoot) {
20
+ if (!config.worktrees) return { worktree: repoRoot, willCreate: false };
21
+ const root = config.worktreeRoot ? resolve(repoRoot, config.worktreeRoot) : join(dirname(repoRoot), '.loop-worktrees');
22
+ const existing = await git.worktreePath(branch);
23
+ if (existing) return { worktree: existing, willCreate: false };
24
+ return { worktree: join(root, slug), willCreate: true };
25
+ }
26
+
27
+ export async function setupWorktree(git, config, slug, branch, repoRoot, baseBranch) {
28
+ const plan = await planWorktree(git, config, slug, branch, repoRoot);
29
+ if (!plan.willCreate) return { worktree: plan.worktree, created: false, worktreeCreated: false };
30
+ await mkdir(dirname(plan.worktree), { recursive: true });
31
+ const result = await git.addWorktree(plan.worktree, branch, baseBranch);
32
+ return { worktree: plan.worktree, created: result.created, worktreeCreated: true };
33
+ }
@@ -1,44 +0,0 @@
1
- The code-producing phases have already committed their work in `{{REPO}}`, on
2
- branch `{{BRANCH}}`. Verify the worktree is clean and do not create any code
3
- commits in this phase.
4
-
5
- - Inspect the full worktree and recent branch history before pushing.
6
- - Do _NOT_ include notes about co-author / author.
7
- - Report the existing code commit hashes and messages, validation performed,
8
- and whether the worktree is clean.
9
- - Push to `{{REMOTE}}` and create a PR to `{{BASE_BRANCH}}` on the same repo. PR
10
- body should be < 250 words.
11
-
12
- The PR body should cover what changed and why, a summary of the task, the
13
- review outcome (how many rounds, plus any recorded disagreements from the task file's
14
- `## Code Review` section if present), and anything deliberately left out of scope.
15
-
16
- ## The task file
17
-
18
- If a task file is present (`{{TASK_FILE}}`), update it without committing it:
19
-
20
- - Set the frontmatter `status` to `UNDER REVIEW`.
21
- - Append a `## Changelog` entry listing the code commits (short SHA + subject)
22
- and the PR URL once you have it.
23
- - Use only `TODO`, `IN PROGRESS`, `UNDER REVIEW`, or `DONE` as statuses.
24
-
25
- A separate job or workflow commits task file changes if needed. Do not commit the
26
- task file or make any other commits in its repository.
27
-
28
- ## Boundaries
29
-
30
- - If the worktree is **not** clean — `git status --porcelain` reports anything —
31
- do **not** edit files, tests, or configuration to make it clean, and do not
32
- create commits. A dirty tree at this phase is a pipeline error: stop
33
- immediately and report the offending paths. Reconciling it is out of scope
34
- here, and the earlier phase that left the work uncommitted must be resumed
35
- instead.
36
- - **Do not merge the pull request**, and do not enable auto-merge. A human
37
- decides; an open PR is the correct end state for this run.
38
- - Never force-push, and never rewrite commits that already exist on
39
- `{{REMOTE}}`.
40
- - If the push is rejected because the branch moved, stop and report it rather
41
- than forcing anything.
42
-
43
- The separate task-file workflow and cron process own updates to that repository;
44
- keep code and task-file commits separate.