opencode-ship 1.1.2-rc.2 → 1.1.2
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/CHANGELOG.md +96 -1
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/agents/delivery-reviewer.md +1 -0
- package/assets/agents/delivery-verifier.md +1 -0
- package/assets/agents/ship-controller.md +20 -7
- package/assets/agents/ship-final-spec-reviewer.md +5 -3
- package/assets/agents/ship-final-standards-reviewer.md +5 -3
- package/assets/agents/ship-planner.md +1 -0
- package/assets/agents/ship-task-builder.md +1 -0
- package/assets/agents/ship-task-reviewer.md +1 -0
- package/dist/cli.js +2274 -362
- package/dist/core.js +794 -10
- package/dist/plugin.js +3487 -1795
- package/package.json +9 -4
- package/tests/plugin/expected-tools.mjs +2 -2
- package/tests/plugin/plugin-load.test.mjs +8 -3
- package/assets/_archive/issue-tracker-gitlab.md +0 -46
- package/assets/_archive/issue-tracker-local.md +0 -30
- package/docs/adr/0001-opencode-ship-redesign.md +0 -168
- package/docs/release/1.0.0-execution-plan.md +0 -1063
- package/docs/release/1.1.1-stabilization-plan.md +0 -655
- package/vendor/upstreams/mattpocock/skills/engineering/code-review/SKILL.md +0 -89
- package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DEEPENING.md +0 -37
- package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DESIGN-IT-TWICE.md +0 -44
- package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/SKILL.md +0 -114
- package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/ADR-FORMAT.md +0 -47
- package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/CONTEXT-FORMAT.md +0 -60
- package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/SKILL.md +0 -74
- package/vendor/upstreams/mattpocock/skills/engineering/grill-with-docs/SKILL.md +0 -7
- package/vendor/upstreams/mattpocock/skills/engineering/prototype/LOGIC.md +0 -79
- package/vendor/upstreams/mattpocock/skills/engineering/prototype/SKILL.md +0 -26
- package/vendor/upstreams/mattpocock/skills/engineering/prototype/UI.md +0 -112
- package/vendor/upstreams/mattpocock/skills/engineering/research/SKILL.md +0 -12
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/SKILL.md +0 -116
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/domain.md +0 -51
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-github.md +0 -45
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-gitlab.md +0 -46
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-local.md +0 -30
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/triage-labels.md +0 -15
- package/vendor/upstreams/mattpocock/skills/engineering/to-spec/SKILL.md +0 -75
- package/vendor/upstreams/mattpocock/skills/engineering/to-tickets/SKILL.md +0 -105
- package/vendor/upstreams/mattpocock/skills/engineering/triage/AGENT-BRIEF.md +0 -207
- package/vendor/upstreams/mattpocock/skills/engineering/triage/OUT-OF-SCOPE.md +0 -105
- package/vendor/upstreams/mattpocock/skills/engineering/triage/SKILL.md +0 -112
- package/vendor/upstreams/mattpocock/skills/engineering/wayfinder/SKILL.md +0 -128
- package/vendor/upstreams/mattpocock/skills/productivity/grilling/SKILL.md +0 -12
- package/vendor/upstreams/mattpocock/skills/productivity/handoff/SKILL.md +0 -16
- package/vendor/upstreams/obra/skills/brainstorming/SKILL.md +0 -151
- package/vendor/upstreams/obra/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/vendor/upstreams/obra/skills/brainstorming/visual-companion.md +0 -298
- package/vendor/upstreams/obra/skills/dispatching-parallel-agents/SKILL.md +0 -167
- package/vendor/upstreams/obra/skills/executing-plans/SKILL.md +0 -64
- package/vendor/upstreams/obra/skills/receiving-code-review/SKILL.md +0 -205
- package/vendor/upstreams/obra/skills/requesting-code-review/SKILL.md +0 -95
- package/vendor/upstreams/obra/skills/requesting-code-review/code-reviewer.md +0 -172
- package/vendor/upstreams/obra/skills/subagent-driven-development/SKILL.md +0 -503
- package/vendor/upstreams/obra/skills/subagent-driven-development/implementer-prompt.md +0 -142
- package/vendor/upstreams/obra/skills/subagent-driven-development/re-review-prompt.md +0 -106
- package/vendor/upstreams/obra/skills/subagent-driven-development/task-reviewer-prompt.md +0 -185
- package/vendor/upstreams/obra/skills/systematic-debugging/CREATION-LOG.md +0 -119
- package/vendor/upstreams/obra/skills/systematic-debugging/SKILL.md +0 -283
- package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
- package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting.md +0 -115
- package/vendor/upstreams/obra/skills/systematic-debugging/defense-in-depth.md +0 -122
- package/vendor/upstreams/obra/skills/systematic-debugging/find-polluter.sh +0 -72
- package/vendor/upstreams/obra/skills/systematic-debugging/root-cause-tracing.md +0 -169
- package/vendor/upstreams/obra/skills/systematic-debugging/test-academic.md +0 -14
- package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-1.md +0 -58
- package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-2.md +0 -68
- package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-3.md +0 -69
- package/vendor/upstreams/obra/skills/test-driven-development/SKILL.md +0 -320
- package/vendor/upstreams/obra/skills/test-driven-development/writing-good-tests.md +0 -198
- package/vendor/upstreams/obra/skills/verification-before-completion/SKILL.md +0 -120
- package/vendor/upstreams/obra/skills/writing-plans/SKILL.md +0 -168
- package/vendor/upstreams/obra/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
package/dist/core.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// opencode-ship/core v1.1.2
|
|
1
|
+
// opencode-ship/core v1.1.2
|
|
2
2
|
|
|
3
3
|
// src/adapter.js
|
|
4
4
|
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
@@ -195,8 +195,8 @@ async function loadAdapter(repoRoot) {
|
|
|
195
195
|
}
|
|
196
196
|
const v = validateAdapter(parsed);
|
|
197
197
|
if (!v.ok) return { ok: false, error: { kind: "contract", path, issues: v.issues } };
|
|
198
|
-
const
|
|
199
|
-
return { ok: true, adapter: v.adapter, path, sha256 };
|
|
198
|
+
const sha2563 = createHash("sha256").update(raw).digest("hex");
|
|
199
|
+
return { ok: true, adapter: v.adapter, path, sha256: sha2563 };
|
|
200
200
|
}
|
|
201
201
|
async function writeLock(repoRoot, adapterSha256) {
|
|
202
202
|
const lockPath = resolve(repoRoot, ".opencode", LOCK_FILENAME);
|
|
@@ -292,7 +292,7 @@ function transition(m, to, opts) {
|
|
|
292
292
|
function createManifest(input) {
|
|
293
293
|
const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
294
294
|
return {
|
|
295
|
-
schemaVersion:
|
|
295
|
+
schemaVersion: 2,
|
|
296
296
|
taskId: input.taskId,
|
|
297
297
|
repoIdentity: input.repoIdentity,
|
|
298
298
|
issueNumber: input.issueNumber,
|
|
@@ -304,6 +304,7 @@ function createManifest(input) {
|
|
|
304
304
|
lastPrHeadSha: input.lastPrHeadSha ?? null,
|
|
305
305
|
lastReviewerSha: input.lastReviewerSha ?? null,
|
|
306
306
|
lastVerifierSha: input.lastVerifierSha ?? null,
|
|
307
|
+
workflowId: input.workflowId ?? null,
|
|
307
308
|
owner: input.owner,
|
|
308
309
|
state: "issue-linked",
|
|
309
310
|
transitionLog: [],
|
|
@@ -331,6 +332,7 @@ import { join as join4, resolve as resolve4 } from "node:path";
|
|
|
331
332
|
// src/state/git-common-dir.js
|
|
332
333
|
import { spawn } from "node:child_process";
|
|
333
334
|
import { resolve as resolve2, join as join2 } from "node:path";
|
|
335
|
+
var STATE_DIRNAME = "opencode-ship";
|
|
334
336
|
async function resolveGitCommonDir(repoRoot) {
|
|
335
337
|
if (typeof repoRoot !== "string" || repoRoot.length === 0) {
|
|
336
338
|
throw new Error("resolveGitCommonDir: repoRoot must be a non-empty string");
|
|
@@ -366,6 +368,12 @@ async function resolveGitCommonDir(repoRoot) {
|
|
|
366
368
|
});
|
|
367
369
|
});
|
|
368
370
|
}
|
|
371
|
+
function opencodeShipStateDir(commonDir, ...segments) {
|
|
372
|
+
if (typeof commonDir !== "string" || commonDir.length === 0) {
|
|
373
|
+
throw new Error("opencodeShipStateDir: commonDir must be a non-empty string");
|
|
374
|
+
}
|
|
375
|
+
return join2(commonDir, STATE_DIRNAME, ...segments);
|
|
376
|
+
}
|
|
369
377
|
|
|
370
378
|
// src/state/durable-store.js
|
|
371
379
|
import {
|
|
@@ -379,8 +387,10 @@ import {
|
|
|
379
387
|
unlink,
|
|
380
388
|
stat
|
|
381
389
|
} from "node:fs/promises";
|
|
390
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
382
391
|
import { dirname as dirname2, join as join3, resolve as resolve3 } from "node:path";
|
|
383
392
|
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
393
|
+
import { hostname as osHostname } from "node:os";
|
|
384
394
|
var STALE_LOCK_MS = 120 * 1e3;
|
|
385
395
|
function randomToken() {
|
|
386
396
|
return randomBytes(8).toString("hex");
|
|
@@ -417,6 +427,159 @@ async function atomicReplaceJson(path, value) {
|
|
|
417
427
|
await rename2(tmp, target);
|
|
418
428
|
await fsyncDir(parent);
|
|
419
429
|
}
|
|
430
|
+
async function publishImmutableJson(path, value) {
|
|
431
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
432
|
+
throw new Error("publishImmutableJson: path must be a non-empty string");
|
|
433
|
+
}
|
|
434
|
+
const target = resolve3(path);
|
|
435
|
+
const parent = dirname2(target);
|
|
436
|
+
await mkdir2(parent, { recursive: true });
|
|
437
|
+
if (existsSync2(target)) {
|
|
438
|
+
throw new Error(`publishImmutableJson: target already exists: ${target}`);
|
|
439
|
+
}
|
|
440
|
+
const tmp = `${target}.${randomToken()}.immutable.tmp`;
|
|
441
|
+
const handle = await fsOpen(tmp, "w", 384);
|
|
442
|
+
try {
|
|
443
|
+
await handle.writeFile(ensureString(value), "utf8");
|
|
444
|
+
await handle.sync();
|
|
445
|
+
} finally {
|
|
446
|
+
await handle.close();
|
|
447
|
+
}
|
|
448
|
+
const attempt = await tryHardLink(tmp, target);
|
|
449
|
+
if (attempt === "exists") {
|
|
450
|
+
await unlink(tmp).catch(() => null);
|
|
451
|
+
throw new Error(`publishImmutableJson: target already exists: ${target}`);
|
|
452
|
+
}
|
|
453
|
+
if (attempt === "linked") {
|
|
454
|
+
await unlink(tmp).catch(() => null);
|
|
455
|
+
} else {
|
|
456
|
+
try {
|
|
457
|
+
await rename2(tmp, target);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
await unlink(tmp).catch(() => null);
|
|
460
|
+
if (err && err.code === "EEXIST") {
|
|
461
|
+
throw new Error(`publishImmutableJson: target already exists: ${target}`);
|
|
462
|
+
}
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
await fsyncDir(parent);
|
|
467
|
+
}
|
|
468
|
+
async function tryHardLink(src, dst) {
|
|
469
|
+
try {
|
|
470
|
+
await link(src, dst);
|
|
471
|
+
return "linked";
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (err && err.code === "EEXIST") return "exists";
|
|
474
|
+
if (err && (err.code === "ENOTSUP" || err.code === "EPERM" || err.code === "ENOSYS" || err.code === "EOPNOTSUPP" || err.code === "EXDEV")) {
|
|
475
|
+
return "fallback";
|
|
476
|
+
}
|
|
477
|
+
throw err;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async function withResourceLock(stateDir, resourceKey, input) {
|
|
481
|
+
if (typeof stateDir !== "string" || stateDir.length === 0) {
|
|
482
|
+
throw new Error("withResourceLock: stateDir must be a non-empty string");
|
|
483
|
+
}
|
|
484
|
+
if (typeof resourceKey !== "string" || resourceKey.length === 0) {
|
|
485
|
+
throw new Error("withResourceLock: resourceKey must be a non-empty string");
|
|
486
|
+
}
|
|
487
|
+
const callback = typeof input === "function" ? input : input?.callback;
|
|
488
|
+
const options = typeof input === "function" ? {} : input?.options ?? {};
|
|
489
|
+
if (typeof callback !== "function") {
|
|
490
|
+
throw new Error("withResourceLock: callback must be a function");
|
|
491
|
+
}
|
|
492
|
+
const acquireMode = options.acquire ?? "wait";
|
|
493
|
+
const waitMs = Number.isInteger(options.waitMs) && options.waitMs >= 0 ? options.waitMs : 30 * 1e3;
|
|
494
|
+
const pollMs = Number.isInteger(options.pollMs) && options.pollMs > 0 ? options.pollMs : 25;
|
|
495
|
+
const keyHash = createHash2("sha256").update(resourceKey).digest("hex");
|
|
496
|
+
const lockDir = join3(stateDir, "locks", keyHash);
|
|
497
|
+
const ownerPath = join3(lockDir, "owner.json");
|
|
498
|
+
await mkdir2(stateDir, { recursive: true });
|
|
499
|
+
const deadline = Date.now() + waitMs;
|
|
500
|
+
let acquired = false;
|
|
501
|
+
let quarantinedThisAcquire = false;
|
|
502
|
+
while (!acquired) {
|
|
503
|
+
try {
|
|
504
|
+
await mkdir2(lockDir, { recursive: true });
|
|
505
|
+
const handle = await fsOpen(ownerPath, "wx", 384);
|
|
506
|
+
try {
|
|
507
|
+
const owner = {
|
|
508
|
+
pid: process.pid,
|
|
509
|
+
hostname: osHostname(),
|
|
510
|
+
resource: resourceKey,
|
|
511
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
512
|
+
};
|
|
513
|
+
await handle.writeFile(JSON.stringify(owner, null, 2) + "\n", "utf8");
|
|
514
|
+
await handle.sync();
|
|
515
|
+
} finally {
|
|
516
|
+
await handle.close();
|
|
517
|
+
}
|
|
518
|
+
acquired = true;
|
|
519
|
+
} catch (err) {
|
|
520
|
+
if (err && err.code === "EEXIST") {
|
|
521
|
+
const claimed = await maybeQuarantineStaleLock(lockDir, ownerPath, resourceKey);
|
|
522
|
+
if (claimed) {
|
|
523
|
+
quarantinedThisAcquire = true;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (acquireMode === "try") {
|
|
527
|
+
throw new Error(`withResourceLock: resource is busy: ${resourceKey}`);
|
|
528
|
+
}
|
|
529
|
+
if (Date.now() >= deadline) {
|
|
530
|
+
throw new Error(`withResourceLock: timed out waiting for ${resourceKey}`);
|
|
531
|
+
}
|
|
532
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
throw err;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
return await callback();
|
|
540
|
+
} finally {
|
|
541
|
+
await unlink(ownerPath).catch(() => null);
|
|
542
|
+
if (!quarantinedThisAcquire) {
|
|
543
|
+
await unlink(lockDir).catch(() => null);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function maybeQuarantineStaleLock(lockDir, ownerPath, resourceKey) {
|
|
548
|
+
let raw;
|
|
549
|
+
try {
|
|
550
|
+
raw = await readFile2(ownerPath, "utf8");
|
|
551
|
+
} catch {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
let owner;
|
|
555
|
+
try {
|
|
556
|
+
owner = JSON.parse(raw);
|
|
557
|
+
} catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
if (!owner || typeof owner !== "object") return false;
|
|
561
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
|
|
562
|
+
if (typeof owner.startedAt !== "string") return false;
|
|
563
|
+
const start = Date.parse(owner.startedAt);
|
|
564
|
+
if (!Number.isFinite(start)) return false;
|
|
565
|
+
if (Date.now() - start < STALE_LOCK_MS) return false;
|
|
566
|
+
if (owner.hostname !== osHostname()) return false;
|
|
567
|
+
let alive = true;
|
|
568
|
+
try {
|
|
569
|
+
process.kill(owner.pid, 0);
|
|
570
|
+
} catch (e) {
|
|
571
|
+
alive = e?.code === "EPERM";
|
|
572
|
+
}
|
|
573
|
+
if (alive) return false;
|
|
574
|
+
const stamp = new Date(start).toISOString().replace(/[:.]/g, "-");
|
|
575
|
+
const quarantinePath = join3(lockDir, `stale-${stamp}-${resourceKey.replace(/[^A-Za-z0-9._-]+/g, "_")}.owner.json`);
|
|
576
|
+
try {
|
|
577
|
+
await rename2(ownerPath, quarantinePath);
|
|
578
|
+
} catch {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
return "quarantined";
|
|
582
|
+
}
|
|
420
583
|
|
|
421
584
|
// src/state/manifest-store.js
|
|
422
585
|
var SHIP_DIRNAME = "opencode-ship";
|
|
@@ -488,7 +651,7 @@ async function deleteManifest(repoRoot, taskId) {
|
|
|
488
651
|
// src/drivers/git.js
|
|
489
652
|
import { spawnSync } from "node:child_process";
|
|
490
653
|
import { resolve as resolve5 } from "node:path";
|
|
491
|
-
import { existsSync as
|
|
654
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
492
655
|
var WorktreeRecord = {
|
|
493
656
|
path: "",
|
|
494
657
|
branch: "",
|
|
@@ -548,7 +711,7 @@ function isRebaseInProgress(cwd) {
|
|
|
548
711
|
}
|
|
549
712
|
function safeExists(p) {
|
|
550
713
|
try {
|
|
551
|
-
return
|
|
714
|
+
return existsSync3(p);
|
|
552
715
|
} catch {
|
|
553
716
|
return false;
|
|
554
717
|
}
|
|
@@ -1012,6 +1175,7 @@ function recoverManifestAfterCrash(manifest) {
|
|
|
1012
1175
|
}
|
|
1013
1176
|
|
|
1014
1177
|
// src/doctor.js
|
|
1178
|
+
import { access } from "node:fs/promises";
|
|
1015
1179
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
1016
1180
|
import { resolve as resolve6 } from "node:path";
|
|
1017
1181
|
function runVersion(argv) {
|
|
@@ -1454,6 +1618,77 @@ function createWorktreeTool(deps) {
|
|
|
1454
1618
|
|
|
1455
1619
|
// src/tools/delivery-verify.js
|
|
1456
1620
|
import { spawn as spawn4 } from "node:child_process";
|
|
1621
|
+
|
|
1622
|
+
// src/workflow/gate-receipts.js
|
|
1623
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1624
|
+
import { mkdir as mkdir3, readFile as readFile4 } from "node:fs/promises";
|
|
1625
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
1626
|
+
import { join as join5 } from "node:path";
|
|
1627
|
+
|
|
1628
|
+
// src/installer/json-pointer.js
|
|
1629
|
+
function isObject(v) {
|
|
1630
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
1631
|
+
}
|
|
1632
|
+
function stableStringify(value) {
|
|
1633
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1634
|
+
if (isObject(value)) {
|
|
1635
|
+
const keys = Object.keys(value).sort();
|
|
1636
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
1637
|
+
}
|
|
1638
|
+
return JSON.stringify(value);
|
|
1639
|
+
}
|
|
1640
|
+
function canonicalJson(value) {
|
|
1641
|
+
return stableStringify(value);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// src/workflow/gate-receipts.js
|
|
1645
|
+
var SAFE_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
1646
|
+
var HASH_RE = /^[0-9a-f]{64}$/;
|
|
1647
|
+
var HEAD_RE = /^[0-9a-f]{40}$/;
|
|
1648
|
+
function validateIdentity(taskId, kind, headSha, receiptHash = null) {
|
|
1649
|
+
if (!SAFE_ID_RE.test(taskId) || !SAFE_ID_RE.test(kind) || !HEAD_RE.test(headSha)) {
|
|
1650
|
+
throw new Error("invalid gate receipt identity");
|
|
1651
|
+
}
|
|
1652
|
+
if (receiptHash !== null && !HASH_RE.test(receiptHash)) throw new Error("invalid gate receipt hash");
|
|
1653
|
+
}
|
|
1654
|
+
function hashGateReceipt(receipt) {
|
|
1655
|
+
const { receiptHash: _receiptHash, ...payload } = receipt;
|
|
1656
|
+
return createHash3("sha256").update(canonicalJson(payload), "utf8").digest("hex");
|
|
1657
|
+
}
|
|
1658
|
+
async function publishGateReceipt(repoRoot, taskId, kind, input) {
|
|
1659
|
+
validateIdentity(taskId, kind, input?.headSha);
|
|
1660
|
+
const receipt = { ...input, kind, taskId };
|
|
1661
|
+
receipt.receiptHash = hashGateReceipt(receipt);
|
|
1662
|
+
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
1663
|
+
const dir = join5(opencodeShipStateDir(commonDir), "gate-receipts", taskId, kind, receipt.headSha);
|
|
1664
|
+
await mkdir3(dir, { recursive: true });
|
|
1665
|
+
const path = join5(dir, `${receipt.receiptHash}.json`);
|
|
1666
|
+
if (!existsSync4(path)) await publishImmutableJson(path, receipt);
|
|
1667
|
+
return { receipt, path };
|
|
1668
|
+
}
|
|
1669
|
+
async function readGateReceipt(repoRoot, taskId, kind, receiptHash) {
|
|
1670
|
+
if (!SAFE_ID_RE.test(taskId) || !SAFE_ID_RE.test(kind) || !HASH_RE.test(receiptHash)) {
|
|
1671
|
+
throw new Error("invalid gate receipt lookup");
|
|
1672
|
+
}
|
|
1673
|
+
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
1674
|
+
const root = join5(opencodeShipStateDir(commonDir), "gate-receipts", taskId, kind);
|
|
1675
|
+
if (!existsSync4(root)) return null;
|
|
1676
|
+
const { readdir: readdir4 } = await import("node:fs/promises");
|
|
1677
|
+
for (const head of await readdir4(root)) {
|
|
1678
|
+
validateIdentity(taskId, kind, head, receiptHash);
|
|
1679
|
+
const path = join5(root, head, `${receiptHash}.json`);
|
|
1680
|
+
if (!existsSync4(path)) continue;
|
|
1681
|
+
const receipt = JSON.parse(await readFile4(path, "utf8"));
|
|
1682
|
+
if (receipt.kind !== kind || receipt.taskId !== taskId || hashGateReceipt(receipt) !== receipt.receiptHash) {
|
|
1683
|
+
throw new Error(`invalid ${kind} gate receipt`);
|
|
1684
|
+
}
|
|
1685
|
+
return receipt;
|
|
1686
|
+
}
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// src/tools/delivery-verify.js
|
|
1691
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1457
1692
|
function runCommand(argv, cwd, timeoutMs) {
|
|
1458
1693
|
return new Promise((resolveP, rejectP) => {
|
|
1459
1694
|
const proc = spawn4(argv[0], argv.slice(1), {
|
|
@@ -1520,8 +1755,16 @@ function createVerifyTool(deps) {
|
|
|
1520
1755
|
headSha: head
|
|
1521
1756
|
};
|
|
1522
1757
|
}
|
|
1758
|
+
const { receipt } = await publishGateReceipt(deps.repoRoot, m.taskId, "verification", {
|
|
1759
|
+
headSha: head,
|
|
1760
|
+
commandId: cmd.id,
|
|
1761
|
+
argv: cmd.argv,
|
|
1762
|
+
exitCode: 0,
|
|
1763
|
+
stdoutSha256: createHash4("sha256").update(result.stdout, "utf8").digest("hex"),
|
|
1764
|
+
stderrSha256: createHash4("sha256").update(result.stderr, "utf8").digest("hex")
|
|
1765
|
+
});
|
|
1523
1766
|
const t = transition(
|
|
1524
|
-
{ ...m, lastVerifierSha: head },
|
|
1767
|
+
{ ...m, lastVerifierSha: head, lastVerificationHash: receipt.receiptHash },
|
|
1525
1768
|
"validating",
|
|
1526
1769
|
{ reason: `verify ok (${cmd.id})` }
|
|
1527
1770
|
);
|
|
@@ -1529,6 +1772,7 @@ function createVerifyTool(deps) {
|
|
|
1529
1772
|
const next = {
|
|
1530
1773
|
...m,
|
|
1531
1774
|
lastVerifierSha: head,
|
|
1775
|
+
lastVerificationHash: receipt.receiptHash,
|
|
1532
1776
|
state: t.to,
|
|
1533
1777
|
transitionLog: [
|
|
1534
1778
|
...m.transitionLog,
|
|
@@ -1544,6 +1788,7 @@ function createVerifyTool(deps) {
|
|
|
1544
1788
|
stdoutTail,
|
|
1545
1789
|
stderrTail,
|
|
1546
1790
|
headSha: head,
|
|
1791
|
+
verificationHash: receipt.receiptHash,
|
|
1547
1792
|
manifestPath
|
|
1548
1793
|
};
|
|
1549
1794
|
};
|
|
@@ -1699,6 +1944,476 @@ function createPrTool(deps) {
|
|
|
1699
1944
|
};
|
|
1700
1945
|
}
|
|
1701
1946
|
|
|
1947
|
+
// src/workflow/run-controller.js
|
|
1948
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
1949
|
+
import { readFile as readFile5, writeFile as writeFile3, mkdir as mkdir4, readdir as readdir3 } from "node:fs/promises";
|
|
1950
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
1951
|
+
import { join as join6 } from "node:path";
|
|
1952
|
+
async function readSnapshotFromDisk(repoRoot, workflowId) {
|
|
1953
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
1954
|
+
const runPath = join6(opencodeShipStateDir(common), "runs", workflowId, "run.json");
|
|
1955
|
+
if (!existsSync5(runPath)) return null;
|
|
1956
|
+
try {
|
|
1957
|
+
const raw = await readFile5(runPath, "utf8");
|
|
1958
|
+
return JSON.parse(raw);
|
|
1959
|
+
} catch {
|
|
1960
|
+
return null;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
async function readEventsFromDisk(repoRoot, workflowId) {
|
|
1964
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
1965
|
+
const dir = join6(opencodeShipStateDir(common), "runs", workflowId, "events");
|
|
1966
|
+
if (!existsSync5(dir)) return [];
|
|
1967
|
+
const entries = await readdir3(dir);
|
|
1968
|
+
const sorted = entries.filter((n) => n.endsWith(".json")).sort();
|
|
1969
|
+
const out = [];
|
|
1970
|
+
for (const name of sorted) {
|
|
1971
|
+
const raw = await readFile5(join6(dir, name), "utf8");
|
|
1972
|
+
out.push(JSON.parse(raw));
|
|
1973
|
+
}
|
|
1974
|
+
return out;
|
|
1975
|
+
}
|
|
1976
|
+
var STATES2 = Object.freeze({
|
|
1977
|
+
CREATED: "created",
|
|
1978
|
+
RUNNING: "running",
|
|
1979
|
+
COMMIT_PENDING: "commit-pending",
|
|
1980
|
+
COMMITTED: "committed",
|
|
1981
|
+
FIX_PENDING: "fix-pending",
|
|
1982
|
+
REVISION_REQUIRED: "revision-required",
|
|
1983
|
+
BLOCKED: "blocked",
|
|
1984
|
+
ALL_TASKS_DONE: "all-tasks-done",
|
|
1985
|
+
READY_PENDING: "ready-pending",
|
|
1986
|
+
READY: "ready",
|
|
1987
|
+
MERGED: "merged",
|
|
1988
|
+
DONE: "done"
|
|
1989
|
+
});
|
|
1990
|
+
var EVENT_KINDS = Object.freeze({
|
|
1991
|
+
RUN_START: "run-start",
|
|
1992
|
+
TASK_DISPATCH: "task-dispatch",
|
|
1993
|
+
TASK_REPORT: "task-report",
|
|
1994
|
+
TASK_REVIEW: "task-review",
|
|
1995
|
+
COMMIT: "commit",
|
|
1996
|
+
TASK_COMPLETE: "task-complete",
|
|
1997
|
+
ALL_TASKS_DONE: "all-tasks-done",
|
|
1998
|
+
FINAL_REVIEW: "final-review",
|
|
1999
|
+
READY_PENDING: "ready-pending",
|
|
2000
|
+
READY: "ready",
|
|
2001
|
+
MERGE: "merge",
|
|
2002
|
+
DONE: "done",
|
|
2003
|
+
BLOCKED: "blocked"
|
|
2004
|
+
});
|
|
2005
|
+
var MAX_FIX_ROUNDS = 3;
|
|
2006
|
+
function sha256(value) {
|
|
2007
|
+
return createHash5("sha256").update(value, "utf8").digest("hex");
|
|
2008
|
+
}
|
|
2009
|
+
function canonicalize(value) {
|
|
2010
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
2011
|
+
const sort = (v) => {
|
|
2012
|
+
if (v === null || typeof v !== "object") return v;
|
|
2013
|
+
if (seen.has(v)) return null;
|
|
2014
|
+
seen.add(v);
|
|
2015
|
+
if (Array.isArray(v)) return v.map(sort);
|
|
2016
|
+
const out = {};
|
|
2017
|
+
for (const k of Object.keys(v).sort()) out[k] = sort(v[k]);
|
|
2018
|
+
return out;
|
|
2019
|
+
};
|
|
2020
|
+
return JSON.stringify(sort(value));
|
|
2021
|
+
}
|
|
2022
|
+
function nextRound(failures) {
|
|
2023
|
+
return (failures ?? 0) + 1;
|
|
2024
|
+
}
|
|
2025
|
+
function nextSequence(events) {
|
|
2026
|
+
return events.length + 1;
|
|
2027
|
+
}
|
|
2028
|
+
function appendEvent(state, recorded) {
|
|
2029
|
+
const prev = state.events[state.events.length - 1];
|
|
2030
|
+
const priorHash = prev?.hash ?? "0".repeat(64);
|
|
2031
|
+
const hash = sha256(canonicalize({ kind: recorded.kind, data: recorded.data, at: recorded.at, sequence: recorded.sequence, priorHash }));
|
|
2032
|
+
const withHash = { ...recorded, priorHash, hash };
|
|
2033
|
+
return [...state.events, withHash];
|
|
2034
|
+
}
|
|
2035
|
+
function normalizeState(state) {
|
|
2036
|
+
return {
|
|
2037
|
+
...state,
|
|
2038
|
+
completedTasks: Array.isArray(state.completedTasks) ? state.completedTasks : [],
|
|
2039
|
+
taskReady: state.taskReady ?? null,
|
|
2040
|
+
events: Array.isArray(state.events) ? state.events : [],
|
|
2041
|
+
round: Number.isInteger(state.round) ? state.round : 0,
|
|
2042
|
+
failures: Number.isInteger(state.failures) ? state.failures : 0
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
function ensureActiveTask(state, taskId) {
|
|
2046
|
+
if (state.activeTask === null) return;
|
|
2047
|
+
if (state.activeTask !== taskId) {
|
|
2048
|
+
throw new Error(`run reducer: another task is active (${state.activeTask}), refusing ${taskId}`);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
function reduce(state, event) {
|
|
2052
|
+
const ev = { ...event, at: event.at ?? (/* @__PURE__ */ new Date()).toISOString() };
|
|
2053
|
+
const sequence = nextSequence(state.events);
|
|
2054
|
+
const recorded = (kind, data) => ({ sequence, kind, at: ev.at, data });
|
|
2055
|
+
const nextState = (extra) => ({ ...state, ...extra, events: appendEvent(state, recorded(event.kind, event.data)) });
|
|
2056
|
+
switch (event.kind) {
|
|
2057
|
+
case EVENT_KINDS.RUN_START: {
|
|
2058
|
+
if (state.state !== STATES2.CREATED) {
|
|
2059
|
+
throw new Error(`run reducer: RUN_START requires state=created, got ${state.state}`);
|
|
2060
|
+
}
|
|
2061
|
+
return {
|
|
2062
|
+
state: nextState({ state: STATES2.RUNNING, activeTask: null, round: 0, completedTasks: [] }),
|
|
2063
|
+
event: recorded(EVENT_KINDS.RUN_START, { revision: event.data.revision, sha256: event.data.sha256 })
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
case EVENT_KINDS.TASK_DISPATCH: {
|
|
2067
|
+
if (state.state !== STATES2.RUNNING && state.state !== STATES2.FIX_PENDING) {
|
|
2068
|
+
throw new Error(`run reducer: TASK_DISPATCH requires state=running|fix-pending, got ${state.state}`);
|
|
2069
|
+
}
|
|
2070
|
+
if (state.activeTask !== null) {
|
|
2071
|
+
throw new Error(`run reducer: TASK_DISPATCH while another task is active (${state.activeTask})`);
|
|
2072
|
+
}
|
|
2073
|
+
const round = state.round > 0 ? state.round : 1;
|
|
2074
|
+
return {
|
|
2075
|
+
state: nextState({ state: STATES2.RUNNING, activeTask: event.data.taskId, round }),
|
|
2076
|
+
event: recorded(EVENT_KINDS.TASK_DISPATCH, { taskId: event.data.taskId, briefHash: event.data.briefHash, round })
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
case EVENT_KINDS.TASK_REPORT: {
|
|
2080
|
+
if (state.state !== STATES2.RUNNING && state.state !== STATES2.FIX_PENDING || state.activeTask === null) {
|
|
2081
|
+
throw new Error(`run reducer: TASK_REPORT requires running with active task`);
|
|
2082
|
+
}
|
|
2083
|
+
ensureActiveTask(state, event.data.taskId);
|
|
2084
|
+
return {
|
|
2085
|
+
state: nextState({ state: STATES2.RUNNING, taskReady: { taskId: event.data.taskId, reportHash: event.data.reportHash } }),
|
|
2086
|
+
event: recorded(EVENT_KINDS.TASK_REPORT, { taskId: event.data.taskId, reportHash: event.data.reportHash })
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
case EVENT_KINDS.TASK_REVIEW: {
|
|
2090
|
+
if (state.state !== STATES2.RUNNING && state.state !== STATES2.FIX_PENDING || state.activeTask === null) {
|
|
2091
|
+
throw new Error(`run reducer: TASK_REVIEW requires running with active task`);
|
|
2092
|
+
}
|
|
2093
|
+
ensureActiveTask(state, event.data.taskId);
|
|
2094
|
+
const failures = event.data.verdict === "pass" ? 0 : nextRound(state.failures);
|
|
2095
|
+
if (event.data.verdict === "pass") {
|
|
2096
|
+
return {
|
|
2097
|
+
state: nextState({ state: STATES2.COMMIT_PENDING, round: state.round, taskReady: { ...state.taskReady ?? {}, taskId: event.data.taskId, reviewHash: event.data.reviewHash } }),
|
|
2098
|
+
event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "pass", reviewHash: event.data.reviewHash })
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
const next = nextRound(state.round);
|
|
2102
|
+
if (failures >= MAX_FIX_ROUNDS) {
|
|
2103
|
+
return {
|
|
2104
|
+
state: nextState({ state: STATES2.REVISION_REQUIRED, failures, round: next, activeTask: null }),
|
|
2105
|
+
event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "fail", failures })
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
return {
|
|
2109
|
+
state: nextState({ state: STATES2.FIX_PENDING, failures, round: next, activeTask: null, taskReady: null }),
|
|
2110
|
+
event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "fail", round: next, failures })
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
case EVENT_KINDS.COMMIT: {
|
|
2114
|
+
if (state.state !== STATES2.COMMIT_PENDING) {
|
|
2115
|
+
throw new Error(`run reducer: COMMIT requires state=commit-pending, got ${state.state}`);
|
|
2116
|
+
}
|
|
2117
|
+
const taskId = state.taskReady?.taskId;
|
|
2118
|
+
const completedTasks = taskId ? state.completedTasks.includes(taskId) ? state.completedTasks : [...state.completedTasks, taskId] : state.completedTasks;
|
|
2119
|
+
return {
|
|
2120
|
+
state: nextState({ state: STATES2.COMMITTED, activeTask: null, round: 0, failures: 0, completedTasks }),
|
|
2121
|
+
event: recorded(EVENT_KINDS.COMMIT, { taskId, commitSha: event.data.commitSha })
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
case EVENT_KINDS.TASK_COMPLETE: {
|
|
2125
|
+
if (state.state !== STATES2.COMMITTED) {
|
|
2126
|
+
throw new Error(`run reducer: TASK_COMPLETE requires state=committed, got ${state.state}`);
|
|
2127
|
+
}
|
|
2128
|
+
const moreTasks = event.data.moreTasks === false ? false : true;
|
|
2129
|
+
const nextStateObj = moreTasks ? { state: STATES2.RUNNING, activeTask: null, taskReady: null, round: 0, failures: 0 } : { state: STATES2.ALL_TASKS_DONE, activeTask: null, taskReady: null };
|
|
2130
|
+
return {
|
|
2131
|
+
state: nextState(nextStateObj),
|
|
2132
|
+
event: recorded(EVENT_KINDS.TASK_COMPLETE, { taskId: event.data.taskId, moreTasks, nextTaskId: event.data.nextTaskId ?? null })
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
case EVENT_KINDS.FINAL_REVIEW: {
|
|
2136
|
+
if (state.state !== STATES2.ALL_TASKS_DONE && state.state !== STATES2.READY_PENDING) {
|
|
2137
|
+
throw new Error(`run reducer: FINAL_REVIEW requires state=all-tasks-done|ready-pending, got ${state.state}`);
|
|
2138
|
+
}
|
|
2139
|
+
const incomingHash = event.data.packageHash;
|
|
2140
|
+
const incomingHead = event.data.headSha;
|
|
2141
|
+
const incomingMergeBase = event.data.mergeBaseSha;
|
|
2142
|
+
const finalReview = { ...state.finalReview ?? {} };
|
|
2143
|
+
if (state.state === STATES2.ALL_TASKS_DONE) {
|
|
2144
|
+
finalReview.packageHash = incomingHash;
|
|
2145
|
+
finalReview.headSha = incomingHead;
|
|
2146
|
+
finalReview.mergeBaseSha = incomingMergeBase;
|
|
2147
|
+
} else {
|
|
2148
|
+
if (finalReview.packageHash !== incomingHash) {
|
|
2149
|
+
throw new Error(`run reducer: FINAL_REVIEW axis ${event.data.axis} disagrees with package hash`);
|
|
2150
|
+
}
|
|
2151
|
+
if (finalReview.headSha !== incomingHead) {
|
|
2152
|
+
throw new Error(`run reducer: FINAL_REVIEW axis ${event.data.axis} disagrees with HEAD`);
|
|
2153
|
+
}
|
|
2154
|
+
if (finalReview.mergeBaseSha !== incomingMergeBase) {
|
|
2155
|
+
throw new Error(`run reducer: FINAL_REVIEW axis ${event.data.axis} disagrees with merge-base`);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
finalReview[event.data.axis] = event.data.review;
|
|
2159
|
+
return {
|
|
2160
|
+
state: nextState({
|
|
2161
|
+
state: STATES2.READY_PENDING,
|
|
2162
|
+
finalReview
|
|
2163
|
+
}),
|
|
2164
|
+
event: recorded(EVENT_KINDS.FINAL_REVIEW, {
|
|
2165
|
+
axis: event.data.axis,
|
|
2166
|
+
verdict: event.data.verdict,
|
|
2167
|
+
headSha: incomingHead,
|
|
2168
|
+
mergeBaseSha: incomingMergeBase,
|
|
2169
|
+
packageHash: incomingHash,
|
|
2170
|
+
sessionID: event.data.sessionID,
|
|
2171
|
+
review: event.data.review
|
|
2172
|
+
})
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
case EVENT_KINDS.READY: {
|
|
2176
|
+
if (state.state !== STATES2.COMMITTED && state.state !== STATES2.READY_PENDING) {
|
|
2177
|
+
throw new Error(`run reducer: READY requires state=committed|ready-pending, got ${state.state}`);
|
|
2178
|
+
}
|
|
2179
|
+
if (state.state === STATES2.READY_PENDING && (!state.finalReview?.standards || !state.finalReview?.spec)) {
|
|
2180
|
+
throw new Error(`run reducer: READY requires both Standards and Spec final reviews`);
|
|
2181
|
+
}
|
|
2182
|
+
if (state.state === STATES2.READY_PENDING && state.finalReview.headSha && event.data.headSha && state.finalReview.headSha !== event.data.headSha) {
|
|
2183
|
+
throw new Error(`run reducer: READY head drift (finalReview=${state.finalReview.headSha.slice(0, 8)}, ready=${event.data.headSha.slice(0, 8)})`);
|
|
2184
|
+
}
|
|
2185
|
+
return {
|
|
2186
|
+
state: nextState({ state: STATES2.READY, activeTask: null, completedTasks: state.completedTasks }),
|
|
2187
|
+
event: recorded(EVENT_KINDS.READY, { headSha: event.data.headSha })
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
case EVENT_KINDS.MERGE: {
|
|
2191
|
+
if (state.state !== STATES2.READY) {
|
|
2192
|
+
throw new Error(`run reducer: MERGE requires state=ready, got ${state.state}`);
|
|
2193
|
+
}
|
|
2194
|
+
return {
|
|
2195
|
+
state: nextState({ state: STATES2.MERGED, mergedAt: ev.at, mergeSha: event.data.mergeSha }),
|
|
2196
|
+
event: recorded(EVENT_KINDS.MERGE, { mergeSha: event.data.mergeSha })
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
case EVENT_KINDS.DONE: {
|
|
2200
|
+
if (state.state !== STATES2.MERGED) {
|
|
2201
|
+
throw new Error(`run reducer: DONE requires state=merged, got ${state.state}`);
|
|
2202
|
+
}
|
|
2203
|
+
return {
|
|
2204
|
+
state: nextState({ state: STATES2.DONE, activeTask: null }),
|
|
2205
|
+
event: recorded(EVENT_KINDS.DONE, { taskId: event.data.taskId })
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
case EVENT_KINDS.BLOCKED: {
|
|
2209
|
+
return {
|
|
2210
|
+
state: nextState({ state: STATES2.BLOCKED, blockedReason: event.data.reason }),
|
|
2211
|
+
event: recorded(EVENT_KINDS.BLOCKED, { reason: event.data.reason })
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
default:
|
|
2215
|
+
throw new Error(`run reducer: unknown event kind ${event.kind}`);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
function createInitialState(workflowId, revision, sha2563) {
|
|
2219
|
+
return {
|
|
2220
|
+
workflowId,
|
|
2221
|
+
revision,
|
|
2222
|
+
sha256: sha2563,
|
|
2223
|
+
state: STATES2.CREATED,
|
|
2224
|
+
activeTask: null,
|
|
2225
|
+
taskReady: null,
|
|
2226
|
+
round: 0,
|
|
2227
|
+
failures: 0,
|
|
2228
|
+
completedTasks: [],
|
|
2229
|
+
events: []
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
var RUN_EVENT_KINDS = EVENT_KINDS;
|
|
2233
|
+
function snapshotFields(state) {
|
|
2234
|
+
return {
|
|
2235
|
+
workflowId: state.workflowId,
|
|
2236
|
+
revision: state.revision,
|
|
2237
|
+
sha256: state.sha256,
|
|
2238
|
+
state: state.state,
|
|
2239
|
+
activeTask: state.activeTask,
|
|
2240
|
+
taskReady: state.taskReady ?? null,
|
|
2241
|
+
failures: state.failures,
|
|
2242
|
+
round: state.round,
|
|
2243
|
+
completedTasks: state.completedTasks,
|
|
2244
|
+
finalReview: state.finalReview ?? null
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
function replayPersistedRun(workflowId, snapshot, events) {
|
|
2248
|
+
if (!snapshot || events.length === 0) {
|
|
2249
|
+
throw new Error("run state has no immutable event ledger");
|
|
2250
|
+
}
|
|
2251
|
+
const first = events[0];
|
|
2252
|
+
if (first.kind !== EVENT_KINDS.RUN_START) {
|
|
2253
|
+
throw new Error("run event ledger must begin with run-start");
|
|
2254
|
+
}
|
|
2255
|
+
let state = createInitialState(workflowId, first.data?.revision, first.data?.sha256);
|
|
2256
|
+
let priorHash = "0".repeat(64);
|
|
2257
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
2258
|
+
const persisted = events[index];
|
|
2259
|
+
if (persisted.sequence !== index + 1 || persisted.priorHash !== priorHash) {
|
|
2260
|
+
throw new Error(`run event ledger chain mismatch at sequence ${index + 1}`);
|
|
2261
|
+
}
|
|
2262
|
+
const expectedHash = sha256(canonicalize({
|
|
2263
|
+
kind: persisted.kind,
|
|
2264
|
+
data: persisted.data,
|
|
2265
|
+
at: persisted.at,
|
|
2266
|
+
sequence: persisted.sequence,
|
|
2267
|
+
priorHash
|
|
2268
|
+
}));
|
|
2269
|
+
if (persisted.hash !== expectedHash) {
|
|
2270
|
+
throw new Error(`run event ledger hash mismatch at sequence ${index + 1}`);
|
|
2271
|
+
}
|
|
2272
|
+
const reduced = reduce(state, { kind: persisted.kind, data: persisted.data, at: persisted.at });
|
|
2273
|
+
const replayedEvent = reduced.state.events.at(-1);
|
|
2274
|
+
if (replayedEvent.hash !== persisted.hash) {
|
|
2275
|
+
throw new Error(`run event ledger replay mismatch at sequence ${index + 1}`);
|
|
2276
|
+
}
|
|
2277
|
+
state = reduced.state;
|
|
2278
|
+
priorHash = persisted.hash;
|
|
2279
|
+
}
|
|
2280
|
+
if (canonicalize(snapshotFields(state)) !== canonicalize(snapshotFields(snapshot))) {
|
|
2281
|
+
throw new Error("run snapshot does not match immutable event ledger");
|
|
2282
|
+
}
|
|
2283
|
+
return state;
|
|
2284
|
+
}
|
|
2285
|
+
async function runDir(repoRoot, workflowId) {
|
|
2286
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
2287
|
+
return join6(opencodeShipStateDir(common), "runs", workflowId);
|
|
2288
|
+
}
|
|
2289
|
+
async function appendRunEvent(repoRoot, workflowId, state, event) {
|
|
2290
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
2291
|
+
const dir = join6(opencodeShipStateDir(common), "runs", workflowId, "events");
|
|
2292
|
+
await mkdir4(dir, { recursive: true });
|
|
2293
|
+
const lockKey = `run:${workflowId}`;
|
|
2294
|
+
return withResourceLock(opencodeShipStateDir(common), lockKey, async () => {
|
|
2295
|
+
const persistedEvents = await readEventsFromDisk(repoRoot, workflowId);
|
|
2296
|
+
const persistedSnapshot = await readSnapshotFromDisk(repoRoot, workflowId);
|
|
2297
|
+
const liveState = persistedEvents.length > 0 ? replayPersistedRun(workflowId, persistedSnapshot, persistedEvents) : normalizeState(state);
|
|
2298
|
+
const { state: next, event: recorded } = reduce(liveState, event);
|
|
2299
|
+
const chainedEvent = next.events.at(-1);
|
|
2300
|
+
const sequence = String(recorded.sequence).padStart(8, "0");
|
|
2301
|
+
const path = join6(dir, `${sequence}.json`);
|
|
2302
|
+
await publishImmutableJson(path, chainedEvent);
|
|
2303
|
+
const runPath = join6(opencodeShipStateDir(common), "runs", workflowId, "run.json");
|
|
2304
|
+
const snapshot = {
|
|
2305
|
+
...snapshotFields(next),
|
|
2306
|
+
lastEvent: chainedEvent,
|
|
2307
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2308
|
+
};
|
|
2309
|
+
await writeFile3(runPath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
2310
|
+
return { state: next, event: chainedEvent };
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
async function readRunState(repoRoot, workflowId) {
|
|
2314
|
+
const dir = await runDir(repoRoot, workflowId);
|
|
2315
|
+
const runPath = join6(dir, "run.json");
|
|
2316
|
+
if (!existsSync5(runPath)) return null;
|
|
2317
|
+
const snapshot = JSON.parse(await readFile5(runPath, "utf8"));
|
|
2318
|
+
const eventsDir = join6(dir, "events");
|
|
2319
|
+
const events = existsSync5(eventsDir) ? await Promise.all(
|
|
2320
|
+
(await readdir3(eventsDir)).filter((n) => n.endsWith(".json")).sort().map(async (n) => JSON.parse(await readFile5(join6(eventsDir, n), "utf8")))
|
|
2321
|
+
) : [];
|
|
2322
|
+
return replayPersistedRun(workflowId, snapshot, events);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// src/workflow/final-review-store.js
|
|
2326
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
2327
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
2328
|
+
import { join as join7 } from "node:path";
|
|
2329
|
+
|
|
2330
|
+
// src/workflow/final-review.js
|
|
2331
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
2332
|
+
function hashFinalReviewPackage(pkg) {
|
|
2333
|
+
const { packageHash: _packageHash, ...payload } = pkg;
|
|
2334
|
+
return sha2562(canonicalJson(payload));
|
|
2335
|
+
}
|
|
2336
|
+
function hashAxisRecord(record) {
|
|
2337
|
+
const { reviewHash: _reviewHash, ...payload } = record;
|
|
2338
|
+
return sha2562(canonicalJson(payload));
|
|
2339
|
+
}
|
|
2340
|
+
function bindFinalReview(standards, spec) {
|
|
2341
|
+
if (standards.headSha !== spec.headSha) {
|
|
2342
|
+
return { ok: false, reason: `head-mismatch: standards=${standards.headSha} spec=${spec.headSha}` };
|
|
2343
|
+
}
|
|
2344
|
+
if (standards.mergeBaseSha !== spec.mergeBaseSha) {
|
|
2345
|
+
return { ok: false, reason: `merge-base-mismatch: standards=${standards.mergeBaseSha} spec=${spec.mergeBaseSha}` };
|
|
2346
|
+
}
|
|
2347
|
+
if (standards.packageHash !== spec.packageHash) {
|
|
2348
|
+
return { ok: false, reason: `package-mismatch: standards=${standards.packageHash} spec=${spec.packageHash}` };
|
|
2349
|
+
}
|
|
2350
|
+
if (standards.verdict !== "pass" || spec.verdict !== "pass") {
|
|
2351
|
+
return { ok: false, reason: `verdict: standards=${standards.verdict} spec=${spec.verdict}` };
|
|
2352
|
+
}
|
|
2353
|
+
const blocking = [...standards.findings ?? [], ...spec.findings ?? []].filter((f) => f.severity === "blocking");
|
|
2354
|
+
if (blocking.length > 0) {
|
|
2355
|
+
return { ok: false, reason: `blocking-findings: ${blocking.length}` };
|
|
2356
|
+
}
|
|
2357
|
+
return { ok: true, headSha: standards.headSha };
|
|
2358
|
+
}
|
|
2359
|
+
function sha2562(text) {
|
|
2360
|
+
return createHash6("sha256").update(text, "utf8").digest("hex");
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
// src/workflow/final-review-store.js
|
|
2364
|
+
async function readFinalReviewEvidence(repoRoot, workflowId) {
|
|
2365
|
+
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
2366
|
+
const root = join7(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review");
|
|
2367
|
+
const paths = {
|
|
2368
|
+
package: join7(root, "package.json"),
|
|
2369
|
+
standards: join7(root, "standards", "review.json"),
|
|
2370
|
+
spec: join7(root, "spec", "review.json")
|
|
2371
|
+
};
|
|
2372
|
+
for (const [kind, path] of Object.entries(paths)) {
|
|
2373
|
+
if (!existsSync6(path)) throw new Error(`canonical final review ${kind} record is missing`);
|
|
2374
|
+
}
|
|
2375
|
+
const [pkg, standards, spec, runState] = await Promise.all([
|
|
2376
|
+
readJson(paths.package),
|
|
2377
|
+
readJson(paths.standards),
|
|
2378
|
+
readJson(paths.spec),
|
|
2379
|
+
readRunState(repoRoot, workflowId)
|
|
2380
|
+
]);
|
|
2381
|
+
if (hashFinalReviewPackage(pkg) !== pkg.packageHash) {
|
|
2382
|
+
throw new Error("canonical final review package hash is invalid");
|
|
2383
|
+
}
|
|
2384
|
+
const [verification, ci] = await Promise.all([
|
|
2385
|
+
readGateReceipt(repoRoot, pkg.gateTaskId, "verification", pkg.verificationHash),
|
|
2386
|
+
readGateReceipt(repoRoot, pkg.gateTaskId, "ci", pkg.ciHash)
|
|
2387
|
+
]);
|
|
2388
|
+
if (!verification || verification.headSha !== pkg.headSha || verification.exitCode !== 0) {
|
|
2389
|
+
throw new Error("canonical verification receipt does not match the final review package");
|
|
2390
|
+
}
|
|
2391
|
+
if (!ci || ci.headSha !== pkg.headSha || ci.checks?.some((check) => check.bucket !== "pass")) {
|
|
2392
|
+
throw new Error("canonical CI receipt does not match the final review package");
|
|
2393
|
+
}
|
|
2394
|
+
for (const [axis, record] of [["standards", standards], ["spec", spec]]) {
|
|
2395
|
+
if (record.axis !== axis || hashAxisRecord(record) !== record.reviewHash) {
|
|
2396
|
+
throw new Error(`canonical ${axis} review record hash is invalid`);
|
|
2397
|
+
}
|
|
2398
|
+
if (record.packageHash !== pkg.packageHash || record.headSha !== pkg.headSha || record.mergeBaseSha !== pkg.mergeBaseSha) {
|
|
2399
|
+
throw new Error(`canonical ${axis} review does not match the final review package`);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
const binding = bindFinalReview(standards, spec);
|
|
2403
|
+
if (!binding.ok) throw new Error(`canonical final review is not passing: ${binding.reason}`);
|
|
2404
|
+
if (!runState || !["ready-pending", "ready", "merged", "done"].includes(runState.state)) {
|
|
2405
|
+
throw new Error(`workflow is not final-review complete (state=${runState?.state ?? "missing"})`);
|
|
2406
|
+
}
|
|
2407
|
+
const summary = runState.finalReview;
|
|
2408
|
+
if (summary?.packageHash !== pkg.packageHash || summary?.headSha !== pkg.headSha || summary?.mergeBaseSha !== pkg.mergeBaseSha || summary?.standards?.reviewHash !== standards.reviewHash || summary?.spec?.reviewHash !== spec.reviewHash) {
|
|
2409
|
+
throw new Error("workflow final-review summary does not match immutable review records");
|
|
2410
|
+
}
|
|
2411
|
+
return { package: pkg, standards, spec, verification, ci, runState };
|
|
2412
|
+
}
|
|
2413
|
+
async function readJson(path) {
|
|
2414
|
+
return JSON.parse(await readFile6(path, "utf8"));
|
|
2415
|
+
}
|
|
2416
|
+
|
|
1702
2417
|
// src/tools/delivery-ready.js
|
|
1703
2418
|
function createReadyTool(deps) {
|
|
1704
2419
|
return async function ready(input) {
|
|
@@ -1721,8 +2436,32 @@ function createReadyTool(deps) {
|
|
|
1721
2436
|
branch: m.branch,
|
|
1722
2437
|
required: deps.adapter?.ci?.requiredChecks ?? []
|
|
1723
2438
|
}) : [];
|
|
2439
|
+
let runState = null;
|
|
2440
|
+
let gateManifest = m;
|
|
2441
|
+
const suppliedWorkflowId = input.workflowId ? String(input.workflowId) : null;
|
|
2442
|
+
if (m.workflowId && suppliedWorkflowId && m.workflowId !== suppliedWorkflowId) {
|
|
2443
|
+
return { kind: "workflow-mismatch", expected: m.workflowId, received: suppliedWorkflowId };
|
|
2444
|
+
}
|
|
2445
|
+
const workflowId = m.workflowId ?? suppliedWorkflowId;
|
|
2446
|
+
if (m.schemaVersion >= 2 && !workflowId) {
|
|
2447
|
+
return { kind: "missing-workflow-link", taskId: m.taskId };
|
|
2448
|
+
}
|
|
2449
|
+
let finalEvidence = null;
|
|
2450
|
+
if (workflowId) {
|
|
2451
|
+
try {
|
|
2452
|
+
finalEvidence = await readFinalReviewEvidence(deps.repoRoot, workflowId);
|
|
2453
|
+
} catch (err) {
|
|
2454
|
+
return { kind: "invalid-final-review-evidence", workflowId, reason: String(err?.message ?? err) };
|
|
2455
|
+
}
|
|
2456
|
+
runState = finalEvidence.runState;
|
|
2457
|
+
gateManifest = {
|
|
2458
|
+
...m,
|
|
2459
|
+
finalStandardsReview: finalEvidence.standards,
|
|
2460
|
+
finalSpecReview: finalEvidence.spec
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
1724
2463
|
const result = checkGates({
|
|
1725
|
-
manifest: { ...
|
|
2464
|
+
manifest: { ...gateManifest, adapter: deps.adapter },
|
|
1726
2465
|
prHead,
|
|
1727
2466
|
checks,
|
|
1728
2467
|
requires: required
|
|
@@ -1738,6 +2477,10 @@ function createReadyTool(deps) {
|
|
|
1738
2477
|
if (!t.ok) return { kind: "lifecycle", reason: t.reason };
|
|
1739
2478
|
const next = {
|
|
1740
2479
|
...m,
|
|
2480
|
+
workflowId: workflowId ?? null,
|
|
2481
|
+
finalReviewPackageHash: finalEvidence?.package.packageHash ?? m.finalReviewPackageHash ?? null,
|
|
2482
|
+
finalStandardsReview: finalEvidence?.standards ?? m.finalStandardsReview ?? null,
|
|
2483
|
+
finalSpecReview: finalEvidence?.spec ?? m.finalSpecReview ?? null,
|
|
1741
2484
|
lastPrHeadSha: prHead,
|
|
1742
2485
|
state: t.to,
|
|
1743
2486
|
transitionLog: [
|
|
@@ -1747,7 +2490,13 @@ function createReadyTool(deps) {
|
|
|
1747
2490
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1748
2491
|
};
|
|
1749
2492
|
const path = await writeManifest(deps.repoRoot, next);
|
|
1750
|
-
|
|
2493
|
+
if (runState && workflowId) {
|
|
2494
|
+
await appendRunEvent(deps.repoRoot, workflowId, runState, {
|
|
2495
|
+
kind: RUN_EVENT_KINDS.READY,
|
|
2496
|
+
data: { headSha: prHead, taskId: input.taskId }
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
return { contractVersion: 1, manifestPath: path, pr: m.prNumber, workflowId };
|
|
1751
2500
|
};
|
|
1752
2501
|
}
|
|
1753
2502
|
|
|
@@ -1768,6 +2517,16 @@ function createMergeTool(deps) {
|
|
|
1768
2517
|
return { kind: "wrong-base", base: pr.baseRefName };
|
|
1769
2518
|
}
|
|
1770
2519
|
const freshGates = deps.adapter?.merge?.requireFreshGates !== false;
|
|
2520
|
+
let finalEvidence = null;
|
|
2521
|
+
if (m.workflowId) {
|
|
2522
|
+
try {
|
|
2523
|
+
finalEvidence = await readFinalReviewEvidence(deps.repoRoot, m.workflowId);
|
|
2524
|
+
} catch (err) {
|
|
2525
|
+
return { kind: "invalid-final-review-evidence", workflowId: m.workflowId, reason: String(err?.message ?? err) };
|
|
2526
|
+
}
|
|
2527
|
+
} else if (m.schemaVersion >= 2) {
|
|
2528
|
+
return { kind: "missing-workflow-link", taskId: m.taskId };
|
|
2529
|
+
}
|
|
1771
2530
|
if (freshGates) {
|
|
1772
2531
|
const required = deps.adapter?.ready?.requires ?? [
|
|
1773
2532
|
"review",
|
|
@@ -1782,7 +2541,12 @@ function createMergeTool(deps) {
|
|
|
1782
2541
|
required: deps.adapter?.ci?.requiredChecks ?? []
|
|
1783
2542
|
}) : [];
|
|
1784
2543
|
const result = checkGates({
|
|
1785
|
-
manifest: {
|
|
2544
|
+
manifest: {
|
|
2545
|
+
...m,
|
|
2546
|
+
finalStandardsReview: finalEvidence?.standards ?? m.finalStandardsReview,
|
|
2547
|
+
finalSpecReview: finalEvidence?.spec ?? m.finalSpecReview,
|
|
2548
|
+
adapter: deps.adapter
|
|
2549
|
+
},
|
|
1786
2550
|
prHead: pr.headSha,
|
|
1787
2551
|
checks,
|
|
1788
2552
|
requires: required
|
|
@@ -1822,6 +2586,12 @@ function createMergeTool(deps) {
|
|
|
1822
2586
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1823
2587
|
};
|
|
1824
2588
|
const path = await writeManifest(deps.repoRoot, next);
|
|
2589
|
+
if (finalEvidence && m.workflowId) {
|
|
2590
|
+
await appendRunEvent(deps.repoRoot, m.workflowId, finalEvidence.runState, {
|
|
2591
|
+
kind: RUN_EVENT_KINDS.MERGE,
|
|
2592
|
+
data: { mergeSha: merged.mergeSha ?? merged.mergeCommitSha ?? merged.headSha }
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
1825
2595
|
return { kind: "merge", contractVersion: 1, manifestPath: path, pr: m.prNumber, taskId: m.taskId };
|
|
1826
2596
|
};
|
|
1827
2597
|
}
|
|
@@ -1902,6 +2672,14 @@ function createCleanupTool(deps) {
|
|
|
1902
2672
|
};
|
|
1903
2673
|
}
|
|
1904
2674
|
const isBootstrapRecovery = m.state === "cleanup-pending" && m.prNumber === null;
|
|
2675
|
+
let workflowRun = null;
|
|
2676
|
+
if (!isBootstrapRecovery && m.schemaVersion >= 2) {
|
|
2677
|
+
if (!m.workflowId) return { kind: "missing-workflow-link", taskId: m.taskId };
|
|
2678
|
+
workflowRun = await readRunState(deps.repoRoot, m.workflowId);
|
|
2679
|
+
if (!workflowRun || workflowRun.state !== "merged") {
|
|
2680
|
+
return { kind: "workflow-not-merged", workflowId: m.workflowId, state: workflowRun?.state ?? null };
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
1905
2683
|
if (!isBootstrapRecovery && m.prNumber === null) {
|
|
1906
2684
|
return { kind: "missing-pr" };
|
|
1907
2685
|
}
|
|
@@ -1952,6 +2730,12 @@ function createCleanupTool(deps) {
|
|
|
1952
2730
|
if (branchResult.status !== 0 && branchStillExists(deps.repoRoot, m.branch)) {
|
|
1953
2731
|
return { kind: "branch-delete-failed", stderr: branchResult.stderr };
|
|
1954
2732
|
}
|
|
2733
|
+
if (workflowRun && m.workflowId) {
|
|
2734
|
+
await appendRunEvent(deps.repoRoot, m.workflowId, workflowRun, {
|
|
2735
|
+
kind: RUN_EVENT_KINDS.DONE,
|
|
2736
|
+
data: { taskId: m.taskId }
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
1955
2739
|
const tCleanup = transition(m, "cleanup-pending", { reason: "worktree removed" });
|
|
1956
2740
|
const candidate = tCleanup.ok ? {
|
|
1957
2741
|
...m,
|