agent-dealer 1.3.1 → 1.3.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/bundle/server/dist/adapters/git-worktree.js +38 -1
- package/bundle/server/dist/adapters/github.js +50 -0
- package/bundle/server/dist/adapters/github.test.js +89 -1
- package/bundle/server/dist/coordinator/auto-merge.js +46 -9
- package/bundle/server/dist/coordinator/commands.js +89 -9
- package/bundle/server/dist/coordinator/commands.test.js +228 -2
- package/bundle/server/dist/coordinator/developer-effect.js +36 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
- package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
- package/bundle/server/dist/coordinator/prompts.js +57 -2
- package/bundle/server/dist/coordinator/prompts.test.js +236 -2
- package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
- package/bundle/server/dist/coordinator/source-reload.js +44 -9
- package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
- package/bundle/server/dist/coordinator/visual-qa.js +630 -0
- package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
- package/bundle/server/dist/paths.js +24 -0
- package/bundle/server/dist/repository/issues.js +8 -1
- package/bundle/server/dist/repository/issues.test.js +34 -0
- package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
- package/bundle/server/dist/routes/issues.js +23 -8
- package/bundle/server/dist/routes/issues.test.js +80 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
|
@@ -588,6 +588,299 @@ test("NOT-363: a contract-validation failure on refreshed text leaves everything
|
|
|
588
588
|
assert.ok(!listWorkflowEventsForIssue(id).some((e) => e.type === "issue.source_reloaded"));
|
|
589
589
|
await app.close();
|
|
590
590
|
});
|
|
591
|
+
// NOT-422: product-scope pause contract adoption — a `needs_human` issue with an
|
|
592
|
+
// active workflow and an open `product_scope_decision` accepts PATCH task edits
|
|
593
|
+
// and Linear source reload while nothing runs and nothing is pending/leased.
|
|
594
|
+
const { startWorkflow } = await import("../coordinator/commands.js");
|
|
595
|
+
const { enqueueWorkItem, cancelWorkItem } = await import("../repository/work-items.js");
|
|
596
|
+
const { getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
|
|
597
|
+
const { getHumanAction } = await import("../repository/human-actions.js");
|
|
598
|
+
let scopeParkCounter = 0;
|
|
599
|
+
async function parkLinearAtProductScope(app) {
|
|
600
|
+
// Unique external ids per park: several tests park more than one issue, and
|
|
601
|
+
// a repeated import of a live ticket is a 409, not a second row (NOT-141).
|
|
602
|
+
scopeParkCounter += 1;
|
|
603
|
+
const suffix = scopeParkCounter;
|
|
604
|
+
const res = await app.inject({
|
|
605
|
+
method: "POST",
|
|
606
|
+
url: "/api/issues",
|
|
607
|
+
payload: { ...LINEAR_ISSUE_PAYLOAD, externalId: `linear-uuid-scope-${suffix}` },
|
|
608
|
+
});
|
|
609
|
+
assert.equal(res.statusCode, 200, res.body);
|
|
610
|
+
const { id } = res.json();
|
|
611
|
+
assert.equal(startWorkflow(id).ok, true);
|
|
612
|
+
const dev = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
613
|
+
await applyCompletion(dev.id, dev.leaseToken, {
|
|
614
|
+
kind: "clean_handoff",
|
|
615
|
+
branch: "b",
|
|
616
|
+
headSha: "abc",
|
|
617
|
+
baseSha: "base",
|
|
618
|
+
prNumber: 1,
|
|
619
|
+
prUrl: "https://gh/pr/1",
|
|
620
|
+
});
|
|
621
|
+
const rev = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
622
|
+
await applyCompletion(rev.id, rev.leaseToken, {
|
|
623
|
+
kind: "verdict",
|
|
624
|
+
result: ReviewerResult.parse({
|
|
625
|
+
verdict: "escalated",
|
|
626
|
+
productScopeQuestion: "Should deleted users retain sessions?",
|
|
627
|
+
baseSha: "b",
|
|
628
|
+
headSha: "h",
|
|
629
|
+
acceptanceCriteriaAssessment: "ok",
|
|
630
|
+
evidenceAssessment: "ok",
|
|
631
|
+
findings: [],
|
|
632
|
+
risks: [],
|
|
633
|
+
}),
|
|
634
|
+
});
|
|
635
|
+
assert.equal(getIssue(id).status, "needs_human");
|
|
636
|
+
return id;
|
|
637
|
+
}
|
|
638
|
+
test("NOT-422: a product-scope pause accepts PATCH task edits and reports them pending for the next snapshot", async () => {
|
|
639
|
+
const app = await buildApp();
|
|
640
|
+
const id = await parkLinearAtProductScope(app);
|
|
641
|
+
const actionId = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision").id;
|
|
642
|
+
const patchRes = await app.inject({
|
|
643
|
+
method: "PATCH",
|
|
644
|
+
url: `/api/issues/${id}`,
|
|
645
|
+
payload: { description: RELOADED_DESCRIPTION },
|
|
646
|
+
});
|
|
647
|
+
assert.equal(patchRes.statusCode, 200, patchRes.body);
|
|
648
|
+
const patched = patchRes.json();
|
|
649
|
+
assert.equal(patched.description, RELOADED_DESCRIPTION);
|
|
650
|
+
assert.deepStrictEqual(patched.executionContract, EXPECTED_RELOADED_CONTRACT);
|
|
651
|
+
// The scope action stays open and the frozen snapshot stays stale until Resume.
|
|
652
|
+
assert.equal(getHumanAction(actionId).status, "open");
|
|
653
|
+
assert.equal(getTaskSnapshot(getIssue(id)).description, CONTRACT_DESCRIPTION);
|
|
654
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
655
|
+
assert.ok(detail.pendingSnapshotChangedFields.includes("description"));
|
|
656
|
+
assert.ok(detail.pendingSnapshotChangedFields.includes("executionContract"));
|
|
657
|
+
await app.close();
|
|
658
|
+
});
|
|
659
|
+
test("NOT-422: a product-scope pause accepts Linear source reload and preserves the workflow", async () => {
|
|
660
|
+
resetLinearMock(linearNodeForTest(RELOADED_DESCRIPTION));
|
|
661
|
+
const app = await buildApp();
|
|
662
|
+
const id = await parkLinearAtProductScope(app);
|
|
663
|
+
const before = getIssue(id);
|
|
664
|
+
const instanceBefore = getActiveWorkflowInstance(id);
|
|
665
|
+
const actionId = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision").id;
|
|
666
|
+
const res = await app.inject({ method: "POST", url: `/api/issues/${id}/reload-source` });
|
|
667
|
+
assert.equal(res.statusCode, 200, res.body);
|
|
668
|
+
const reloaded = res.json();
|
|
669
|
+
assert.equal(reloaded.title, "NOT-123: Refreshed title from Linear");
|
|
670
|
+
assert.equal(reloaded.description, RELOADED_DESCRIPTION);
|
|
671
|
+
assert.deepStrictEqual(reloaded.executionContract, EXPECTED_RELOADED_CONTRACT);
|
|
672
|
+
assert.ok(reloaded.acceptanceCriteria.includes("- [ ] Reloaded ticket compiles into the refreshed structured schema"));
|
|
673
|
+
assert.ok(!reloaded.acceptanceCriteria.includes("Operator-typed override"));
|
|
674
|
+
const after = getIssue(id);
|
|
675
|
+
assert.equal(after.status, "needs_human");
|
|
676
|
+
assert.equal(after.repo, before.repo);
|
|
677
|
+
assert.equal(after.baseBranch, before.baseBranch);
|
|
678
|
+
assert.equal(after.developerAgentId, before.developerAgentId);
|
|
679
|
+
assert.equal(after.reviewerAgentId, before.reviewerAgentId);
|
|
680
|
+
assert.equal(after.maxReviewRounds, before.maxReviewRounds);
|
|
681
|
+
assert.equal(after.maxInfraAttempts, before.maxInfraAttempts);
|
|
682
|
+
assert.equal(after.autoMerge, before.autoMerge);
|
|
683
|
+
assert.equal(after.currentRound, before.currentRound);
|
|
684
|
+
assert.equal(after.infraAttempts, before.infraAttempts);
|
|
685
|
+
assert.equal(after.branch, before.branch);
|
|
686
|
+
assert.equal(after.baseSha, before.baseSha);
|
|
687
|
+
assert.equal(after.headSha, before.headSha);
|
|
688
|
+
assert.equal(after.prNumber, before.prNumber);
|
|
689
|
+
assert.equal(after.prUrl, before.prUrl);
|
|
690
|
+
assert.equal(getActiveWorkflowInstance(id).id, instanceBefore.id);
|
|
691
|
+
assert.equal(getHumanAction(actionId).status, "open");
|
|
692
|
+
const reloadEvents = listWorkflowEventsForIssue(id).filter((e) => e.type === "issue.source_reloaded");
|
|
693
|
+
assert.equal(reloadEvents.length, 1);
|
|
694
|
+
assert.equal(reloadEvents[0].stage, "needs_human");
|
|
695
|
+
// Still stale until the explicit Resume re-freezes.
|
|
696
|
+
assert.equal(getTaskSnapshot(after).description, CONTRACT_DESCRIPTION);
|
|
697
|
+
await app.close();
|
|
698
|
+
});
|
|
699
|
+
test("NOT-422: PATCH at a scope pause fails closed on pending/leased work, running sessions, and other parks", async () => {
|
|
700
|
+
const app = await buildApp();
|
|
701
|
+
const patch = (id, payload) => app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload });
|
|
702
|
+
// Pending work blocks the edit with no row or event change.
|
|
703
|
+
const pendingId = await parkLinearAtProductScope(app);
|
|
704
|
+
const pendingInstance = getActiveWorkflowInstance(pendingId);
|
|
705
|
+
const item = enqueueWorkItem({
|
|
706
|
+
issueId: pendingId,
|
|
707
|
+
workflowInstanceId: pendingInstance.id,
|
|
708
|
+
kind: "developer",
|
|
709
|
+
round: 1,
|
|
710
|
+
payload: {},
|
|
711
|
+
idempotencyKey: `${pendingInstance.id}:test:n422-route-pending`,
|
|
712
|
+
});
|
|
713
|
+
const beforePending = JSON.stringify(getIssue(pendingId));
|
|
714
|
+
const eventsPending = listWorkflowEventsForIssue(pendingId).length;
|
|
715
|
+
assert.equal((await patch(pendingId, { title: "Racing edit" })).statusCode, 409);
|
|
716
|
+
// Leased work blocks it too.
|
|
717
|
+
const leased = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
718
|
+
assert.equal(leased.id, item.id);
|
|
719
|
+
assert.equal((await patch(pendingId, { title: "Racing edit" })).statusCode, 409);
|
|
720
|
+
cancelWorkItem(item.id);
|
|
721
|
+
assert.equal(JSON.stringify(getIssue(pendingId)), beforePending);
|
|
722
|
+
assert.equal(listWorkflowEventsForIssue(pendingId).length, eventsPending);
|
|
723
|
+
// A running session blocks the edit.
|
|
724
|
+
const runningId = await parkLinearAtProductScope(app);
|
|
725
|
+
const session = createWorkerSession({
|
|
726
|
+
issueId: runningId,
|
|
727
|
+
role: "developer",
|
|
728
|
+
round: 1,
|
|
729
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
730
|
+
runtime: "claude_code",
|
|
731
|
+
});
|
|
732
|
+
startSession(session.id);
|
|
733
|
+
const beforeRunning = JSON.stringify(getIssue(runningId));
|
|
734
|
+
const eventsRunning = listWorkflowEventsForIssue(runningId).length;
|
|
735
|
+
const runningRes = await patch(runningId, { title: "Racing session" });
|
|
736
|
+
assert.equal(runningRes.statusCode, 409);
|
|
737
|
+
assert.match(runningRes.json().error, /actively running session/);
|
|
738
|
+
assert.equal(JSON.stringify(getIssue(runningId)), beforeRunning);
|
|
739
|
+
assert.equal(listWorkflowEventsForIssue(runningId).length, eventsRunning);
|
|
740
|
+
// An agent swap at a scope pause is a 409, not a silent accept.
|
|
741
|
+
const swapId = await parkLinearAtProductScope(app);
|
|
742
|
+
const swapRes = await patch(swapId, { developerAgentId: BUILTIN_AGENT_CURSOR_ID });
|
|
743
|
+
assert.equal(swapRes.statusCode, 409);
|
|
744
|
+
assert.match(swapRes.json().error, /Only title, description and acceptanceCriteria/);
|
|
745
|
+
// A final_review pause is a different park — still 409 on an active workflow.
|
|
746
|
+
const { id: finalId } = await createLinearIssue(app);
|
|
747
|
+
assert.equal(startWorkflow(finalId).ok, true);
|
|
748
|
+
const finalDev = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
749
|
+
await applyCompletion(finalDev.id, finalDev.leaseToken, {
|
|
750
|
+
kind: "clean_handoff",
|
|
751
|
+
branch: "b",
|
|
752
|
+
headSha: "abc",
|
|
753
|
+
baseSha: "base",
|
|
754
|
+
prNumber: 1,
|
|
755
|
+
prUrl: "https://gh/pr/1",
|
|
756
|
+
});
|
|
757
|
+
const finalRev = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
758
|
+
await applyCompletion(finalRev.id, finalRev.leaseToken, {
|
|
759
|
+
kind: "verdict",
|
|
760
|
+
result: ReviewerResult.parse({
|
|
761
|
+
verdict: "approved",
|
|
762
|
+
baseSha: "b",
|
|
763
|
+
headSha: "h",
|
|
764
|
+
acceptanceCriteriaAssessment: "ok",
|
|
765
|
+
evidenceAssessment: "ok",
|
|
766
|
+
findings: [],
|
|
767
|
+
risks: [],
|
|
768
|
+
}),
|
|
769
|
+
});
|
|
770
|
+
assert.equal(getIssue(finalId).status, "final_review");
|
|
771
|
+
const beforeFinal = JSON.stringify(getIssue(finalId));
|
|
772
|
+
const eventsFinal = listWorkflowEventsForIssue(finalId).length;
|
|
773
|
+
assert.equal((await patch(finalId, { title: "Wrong park" })).statusCode, 409);
|
|
774
|
+
assert.equal(JSON.stringify(getIssue(finalId)), beforeFinal);
|
|
775
|
+
assert.equal(listWorkflowEventsForIssue(finalId).length, eventsFinal);
|
|
776
|
+
await app.close();
|
|
777
|
+
});
|
|
778
|
+
test("NOT-422: reload at a scope pause fails closed on pending/leased work, running sessions, and other parks", async () => {
|
|
779
|
+
resetLinearMock(linearNodeForTest(RELOADED_DESCRIPTION));
|
|
780
|
+
const app = await buildApp();
|
|
781
|
+
const reload = (id) => app.inject({ method: "POST", url: `/api/issues/${id}/reload-source` });
|
|
782
|
+
const pendingId = await parkLinearAtProductScope(app);
|
|
783
|
+
const pendingInstance = getActiveWorkflowInstance(pendingId);
|
|
784
|
+
const item = enqueueWorkItem({
|
|
785
|
+
issueId: pendingId,
|
|
786
|
+
workflowInstanceId: pendingInstance.id,
|
|
787
|
+
kind: "developer",
|
|
788
|
+
round: 1,
|
|
789
|
+
payload: {},
|
|
790
|
+
idempotencyKey: `${pendingInstance.id}:test:n422-route-reload-pending`,
|
|
791
|
+
});
|
|
792
|
+
const beforePending = JSON.stringify(getIssue(pendingId));
|
|
793
|
+
const eventsPending = listWorkflowEventsForIssue(pendingId).length;
|
|
794
|
+
assert.equal((await reload(pendingId)).statusCode, 409);
|
|
795
|
+
const leased = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
796
|
+
assert.equal(leased.id, item.id);
|
|
797
|
+
assert.equal((await reload(pendingId)).statusCode, 409);
|
|
798
|
+
cancelWorkItem(item.id);
|
|
799
|
+
assert.equal(JSON.stringify(getIssue(pendingId)), beforePending);
|
|
800
|
+
assert.equal(listWorkflowEventsForIssue(pendingId).length, eventsPending);
|
|
801
|
+
assert.ok(!listWorkflowEventsForIssue(pendingId).some((e) => e.type === "issue.source_reloaded"));
|
|
802
|
+
const runningId = await parkLinearAtProductScope(app);
|
|
803
|
+
const session = createWorkerSession({
|
|
804
|
+
issueId: runningId,
|
|
805
|
+
role: "developer",
|
|
806
|
+
round: 1,
|
|
807
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
808
|
+
runtime: "claude_code",
|
|
809
|
+
});
|
|
810
|
+
startSession(session.id);
|
|
811
|
+
const beforeRunning = JSON.stringify(getIssue(runningId));
|
|
812
|
+
const runningRes = await reload(runningId);
|
|
813
|
+
assert.equal(runningRes.statusCode, 409);
|
|
814
|
+
assert.match(runningRes.json().error, /actively running session/);
|
|
815
|
+
assert.equal(JSON.stringify(getIssue(runningId)), beforeRunning);
|
|
816
|
+
// An attempts-exhausted park accepts PATCH but never a source reload.
|
|
817
|
+
const exhausted = (await app.inject({
|
|
818
|
+
method: "POST",
|
|
819
|
+
url: "/api/issues",
|
|
820
|
+
payload: {
|
|
821
|
+
...LINEAR_ISSUE_PAYLOAD,
|
|
822
|
+
externalId: "linear-uuid-exhausted",
|
|
823
|
+
title: "NOT-123: exhausted",
|
|
824
|
+
maxReviewRounds: 1,
|
|
825
|
+
},
|
|
826
|
+
})).json();
|
|
827
|
+
assert.equal(startWorkflow(exhausted.id).ok, true);
|
|
828
|
+
const exhaustedDev = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
829
|
+
await applyCompletion(exhaustedDev.id, exhaustedDev.leaseToken, {
|
|
830
|
+
kind: "clean_handoff",
|
|
831
|
+
branch: "b",
|
|
832
|
+
headSha: "abc",
|
|
833
|
+
baseSha: "base",
|
|
834
|
+
prNumber: 1,
|
|
835
|
+
prUrl: "https://gh/pr/1",
|
|
836
|
+
});
|
|
837
|
+
const exhaustedRev = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
838
|
+
await applyCompletion(exhaustedRev.id, exhaustedRev.leaseToken, {
|
|
839
|
+
kind: "verdict",
|
|
840
|
+
result: ReviewerResult.parse({
|
|
841
|
+
verdict: "changes_requested",
|
|
842
|
+
baseSha: "b",
|
|
843
|
+
headSha: "h",
|
|
844
|
+
acceptanceCriteriaAssessment: "ok",
|
|
845
|
+
evidenceAssessment: "ok",
|
|
846
|
+
findings: [],
|
|
847
|
+
risks: [],
|
|
848
|
+
}),
|
|
849
|
+
});
|
|
850
|
+
assert.ok(listHumanActionsForIssue(exhausted.id).some((a) => a.actionType === "attempts_exhausted" && a.status === "open"));
|
|
851
|
+
const beforeExhausted = JSON.stringify(getIssue(exhausted.id));
|
|
852
|
+
const eventsExhausted = listWorkflowEventsForIssue(exhausted.id).length;
|
|
853
|
+
assert.equal((await reload(exhausted.id)).statusCode, 409);
|
|
854
|
+
assert.equal(JSON.stringify(getIssue(exhausted.id)), beforeExhausted);
|
|
855
|
+
assert.equal(listWorkflowEventsForIssue(exhausted.id).length, eventsExhausted);
|
|
856
|
+
await app.close();
|
|
857
|
+
});
|
|
858
|
+
test("NOT-422: a malformed amended contract at a scope pause is a 400 that changes nothing", async () => {
|
|
859
|
+
resetLinearMock(linearNodeForTest(CONTRACT_DESCRIPTION.replace("feature", "teleport")));
|
|
860
|
+
const app = await buildApp();
|
|
861
|
+
const patchId = await parkLinearAtProductScope(app);
|
|
862
|
+
const beforePatch = JSON.stringify(getIssue(patchId));
|
|
863
|
+
const eventsPatch = listWorkflowEventsForIssue(patchId).length;
|
|
864
|
+
const badPatch = await app.inject({
|
|
865
|
+
method: "PATCH",
|
|
866
|
+
url: `/api/issues/${patchId}`,
|
|
867
|
+
payload: { description: CONTRACT_DESCRIPTION.replace("feature", "teleport") },
|
|
868
|
+
});
|
|
869
|
+
assert.equal(badPatch.statusCode, 400, badPatch.body);
|
|
870
|
+
assert.match(badPatch.json().error, /unknown execution mode/);
|
|
871
|
+
assert.equal(JSON.stringify(getIssue(patchId)), beforePatch);
|
|
872
|
+
assert.equal(listWorkflowEventsForIssue(patchId).length, eventsPatch);
|
|
873
|
+
const reloadId = await parkLinearAtProductScope(app);
|
|
874
|
+
const beforeReload = JSON.stringify(getIssue(reloadId));
|
|
875
|
+
const eventsReload = listWorkflowEventsForIssue(reloadId).length;
|
|
876
|
+
const badReload = await app.inject({ method: "POST", url: `/api/issues/${reloadId}/reload-source` });
|
|
877
|
+
assert.equal(badReload.statusCode, 400, badReload.body);
|
|
878
|
+
assert.match(badReload.json().error, /unknown execution mode/);
|
|
879
|
+
assert.equal(JSON.stringify(getIssue(reloadId)), beforeReload);
|
|
880
|
+
assert.equal(listWorkflowEventsForIssue(reloadId).length, eventsReload);
|
|
881
|
+
assert.ok(!listWorkflowEventsForIssue(reloadId).some((e) => e.type === "issue.source_reloaded"));
|
|
882
|
+
await app.close();
|
|
883
|
+
});
|
|
591
884
|
test("NOT-363: the frozen snapshot after reload contains exactly the refreshed text", async () => {
|
|
592
885
|
resetLinearMock(linearNodeForTest(RELOADED_DESCRIPTION));
|
|
593
886
|
const app = await buildApp();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// NOT-364: atomic Linear attachment import and reload reconciliation.
|
|
2
2
|
import { test, before, after, beforeEach } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import os from "node:os";
|
|
@@ -445,3 +446,279 @@ test("failed reload preserves the old task text and attachment snapshot", async
|
|
|
445
446
|
assert.equal(createHash("sha256").update(fs.readFileSync(afterRows[0].blobPath)).digest("hex"), beforeBlobHash, "old blob bytes intact");
|
|
446
447
|
await app.close();
|
|
447
448
|
});
|
|
449
|
+
// NOT-422: parked scope reload — the same atomic reconciliation as the
|
|
450
|
+
// pre-execution path, preserving the active workflow and the open action.
|
|
451
|
+
const PARKED_CONTRACT_V1 = [
|
|
452
|
+
"Ship the friend-path run command.",
|
|
453
|
+
"",
|
|
454
|
+
"## Builder execution mode",
|
|
455
|
+
"feature",
|
|
456
|
+
"",
|
|
457
|
+
"## Non-goals",
|
|
458
|
+
"- Rewriting the installer",
|
|
459
|
+
"",
|
|
460
|
+
"## Exit predicate",
|
|
461
|
+
"The friend-path run command works on a clean machine with operator proof.",
|
|
462
|
+
"",
|
|
463
|
+
"## One-PR stopping point",
|
|
464
|
+
"Stop after the run command lands with its regression test.",
|
|
465
|
+
"",
|
|
466
|
+
"## Acceptance criteria",
|
|
467
|
+
"- [ ] Friend-path run works",
|
|
468
|
+
" Evidence: run fixtures | run the tests | green",
|
|
469
|
+
"- [ ] [operator] Clean-machine proof attached",
|
|
470
|
+
" Evidence: operator run | paste the output | reviewed",
|
|
471
|
+
"",
|
|
472
|
+
].join("\n");
|
|
473
|
+
const PARKED_CONTRACT_V2 = [
|
|
474
|
+
"Ship the friend-path run command.",
|
|
475
|
+
"",
|
|
476
|
+
"## Builder execution mode",
|
|
477
|
+
"feature",
|
|
478
|
+
"",
|
|
479
|
+
"## Non-goals",
|
|
480
|
+
"- Rewriting the installer",
|
|
481
|
+
"",
|
|
482
|
+
"## Exit predicate",
|
|
483
|
+
"The friend-path run command works with simulator proof; clean-machine proof moved out.",
|
|
484
|
+
"",
|
|
485
|
+
"## One-PR stopping point",
|
|
486
|
+
"Stop after the run command lands with its regression test.",
|
|
487
|
+
"",
|
|
488
|
+
"## Acceptance criteria",
|
|
489
|
+
"- [ ] Friend-path run works",
|
|
490
|
+
" Evidence: run fixtures | run the tests | green",
|
|
491
|
+
"- [ ] Simulator proof attached",
|
|
492
|
+
" Evidence: simulator run | paste the output | reviewed",
|
|
493
|
+
"",
|
|
494
|
+
].join("\n");
|
|
495
|
+
function parkedNodeWith(description, attachments) {
|
|
496
|
+
return { ...linearNodeWith(attachments), description };
|
|
497
|
+
}
|
|
498
|
+
let scopeAttachCounter = 0;
|
|
499
|
+
async function parkScopeWithAttachments(app) {
|
|
500
|
+
const { startWorkflow, applyCompletion } = await import("../coordinator/commands.js");
|
|
501
|
+
const { claimWorkItem } = await import("../repository/work-items.js");
|
|
502
|
+
const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
|
|
503
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
504
|
+
scopeAttachCounter += 1;
|
|
505
|
+
const created = (await (await app.inject({
|
|
506
|
+
method: "POST",
|
|
507
|
+
url: "/api/issues",
|
|
508
|
+
payload: {
|
|
509
|
+
...BASE_LINEAR,
|
|
510
|
+
externalId: `linear-uuid-scope-att-${scopeAttachCounter}`,
|
|
511
|
+
description: PARKED_CONTRACT_V1,
|
|
512
|
+
linearAttachments: [FILE_ATT, LINK_ATT],
|
|
513
|
+
},
|
|
514
|
+
})).json());
|
|
515
|
+
assert.equal(startWorkflow(created.id).ok, true);
|
|
516
|
+
const dev = claimWorkItem("scope-att-test", { leaseMs: 60_000 });
|
|
517
|
+
await applyCompletion(dev.id, dev.leaseToken, {
|
|
518
|
+
kind: "clean_handoff",
|
|
519
|
+
branch: "b",
|
|
520
|
+
headSha: "abc",
|
|
521
|
+
baseSha: "base",
|
|
522
|
+
prNumber: 1,
|
|
523
|
+
prUrl: "https://gh/pr/1",
|
|
524
|
+
});
|
|
525
|
+
const rev = claimWorkItem("scope-att-test", { leaseMs: 60_000 });
|
|
526
|
+
await applyCompletion(rev.id, rev.leaseToken, {
|
|
527
|
+
kind: "verdict",
|
|
528
|
+
result: ReviewerResult.parse({
|
|
529
|
+
verdict: "escalated",
|
|
530
|
+
productScopeQuestion: "Is clean-machine proof still required?",
|
|
531
|
+
baseSha: "b",
|
|
532
|
+
headSha: "h",
|
|
533
|
+
acceptanceCriteriaAssessment: "ok",
|
|
534
|
+
evidenceAssessment: "ok",
|
|
535
|
+
findings: [],
|
|
536
|
+
risks: [],
|
|
537
|
+
}),
|
|
538
|
+
});
|
|
539
|
+
assert.equal(getIssue(created.id).status, "needs_human");
|
|
540
|
+
return created.id;
|
|
541
|
+
}
|
|
542
|
+
test("NOT-422: parked scope reload recompiles the contract, reconciles attachments, and preserves the workflow", async () => {
|
|
543
|
+
downloadBytes.set(FILE_URL, TARBALL_V1);
|
|
544
|
+
const app = await buildApp();
|
|
545
|
+
const id = await parkScopeWithAttachments(app);
|
|
546
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
547
|
+
const { getActiveWorkflowInstance, listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
548
|
+
const { getHumanAction, listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
549
|
+
const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
550
|
+
const { getTaskSnapshot } = await import("../coordinator/commands.js");
|
|
551
|
+
const before = getIssue(id);
|
|
552
|
+
const instanceBefore = getActiveWorkflowInstance(id);
|
|
553
|
+
const actionBefore = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision");
|
|
554
|
+
const sessionsBefore = listWorkerSessionsForIssue(id);
|
|
555
|
+
const frozenBefore = getTaskSnapshot(before);
|
|
556
|
+
const oldBlob = listSourceAttachments(id).find((r) => r.kind === "file").blobPath;
|
|
557
|
+
assert.ok(fs.existsSync(oldBlob));
|
|
558
|
+
// Linear now: amended contract (the [operator] criterion is gone), the same
|
|
559
|
+
// file id with new bytes, a brand-new file, and the link removed.
|
|
560
|
+
const FILE_B_URL = "https://uploads.linear.app/abc/b.bin";
|
|
561
|
+
const TARBALL_B = Buffer.from("brand-new-bytes");
|
|
562
|
+
downloadBytes.set(FILE_URL, TARBALL_V2);
|
|
563
|
+
downloadBytes.set(FILE_B_URL, TARBALL_B);
|
|
564
|
+
linearNode = parkedNodeWith(PARKED_CONTRACT_V2, [
|
|
565
|
+
{ id: "att-file-1", title: "repro.tar.gz", url: FILE_URL },
|
|
566
|
+
{ id: "att-file-3", title: "b.bin", url: FILE_B_URL },
|
|
567
|
+
]);
|
|
568
|
+
const res = await app.inject({ method: "POST", url: `/api/issues/${id}/reload-source` });
|
|
569
|
+
assert.equal(res.statusCode, 200, res.body);
|
|
570
|
+
const reloaded = res.json();
|
|
571
|
+
assert.equal(reloaded.title, "NOT-1: Reloaded title");
|
|
572
|
+
assert.equal(reloaded.description, PARKED_CONTRACT_V2);
|
|
573
|
+
assert.equal(reloaded.executionContract.exitPredicate, "The friend-path run command works with simulator proof; clean-machine proof moved out.");
|
|
574
|
+
assert.ok(!reloaded.acceptanceCriteria.includes("[operator] Clean-machine proof attached"));
|
|
575
|
+
assert.ok(reloaded.acceptanceCriteria.includes("Simulator proof attached"));
|
|
576
|
+
// Attachments reconciled atomically: changed bytes land, the new file
|
|
577
|
+
// appears, and the removed link disappears. The replaced blob is retained —
|
|
578
|
+
// the frozen snapshot still references it until Resume re-freezes.
|
|
579
|
+
const rows = listSourceAttachments(id);
|
|
580
|
+
assert.deepEqual(rows.map((r) => r.linearAttachmentId).sort(), ["att-file-1", "att-file-3"]);
|
|
581
|
+
const changed = rows.find((r) => r.linearAttachmentId === "att-file-1");
|
|
582
|
+
assert.equal(changed.sha256, createHash("sha256").update(TARBALL_V2).digest("hex"));
|
|
583
|
+
assert.deepEqual(fs.readFileSync(changed.blobPath), TARBALL_V2);
|
|
584
|
+
assert.ok(fs.existsSync(oldBlob), "blob still referenced by the frozen snapshot is retained");
|
|
585
|
+
// Everything else is preserved: repository/configuration, workflow
|
|
586
|
+
// instance, PR/head/branch, rounds, counters, sessions, and the open action.
|
|
587
|
+
const after = getIssue(id);
|
|
588
|
+
const preserved = (issue) => ({
|
|
589
|
+
repo: issue.repo,
|
|
590
|
+
baseBranch: issue.baseBranch,
|
|
591
|
+
developerAgentId: issue.developerAgentId,
|
|
592
|
+
reviewerAgentId: issue.reviewerAgentId,
|
|
593
|
+
maxReviewRounds: issue.maxReviewRounds,
|
|
594
|
+
maxInfraAttempts: issue.maxInfraAttempts,
|
|
595
|
+
maxCiAttempts: issue.maxCiAttempts,
|
|
596
|
+
autoMerge: issue.autoMerge,
|
|
597
|
+
status: issue.status,
|
|
598
|
+
currentRound: issue.currentRound,
|
|
599
|
+
infraAttempts: issue.infraAttempts,
|
|
600
|
+
ciAttempts: issue.ciAttempts,
|
|
601
|
+
branch: issue.branch,
|
|
602
|
+
baseSha: issue.baseSha,
|
|
603
|
+
headSha: issue.headSha,
|
|
604
|
+
prNumber: issue.prNumber,
|
|
605
|
+
prUrl: issue.prUrl,
|
|
606
|
+
source: issue.source,
|
|
607
|
+
externalId: issue.externalId,
|
|
608
|
+
externalLabel: issue.externalLabel,
|
|
609
|
+
externalUrl: issue.externalUrl,
|
|
610
|
+
});
|
|
611
|
+
assert.deepEqual(preserved(after), preserved(before));
|
|
612
|
+
assert.equal(getActiveWorkflowInstance(id).id, instanceBefore.id);
|
|
613
|
+
assert.deepEqual(getHumanAction(actionBefore.id), { ...actionBefore });
|
|
614
|
+
assert.deepEqual(listWorkerSessionsForIssue(id), sessionsBefore);
|
|
615
|
+
assert.equal(listWorkflowEventsForIssue(id).filter((e) => e.type === "issue.source_reloaded").length, 1);
|
|
616
|
+
// The frozen snapshot still carries the old text AND the old manifest until Resume.
|
|
617
|
+
const frozenAfter = getTaskSnapshot(after);
|
|
618
|
+
assert.equal(frozenAfter.description, PARKED_CONTRACT_V1);
|
|
619
|
+
assert.ok(frozenAfter.acceptanceCriteria.includes("[operator] Clean-machine proof attached"));
|
|
620
|
+
assert.deepEqual(frozenAfter.sourceAttachments, frozenBefore.sourceAttachments);
|
|
621
|
+
assert.ok(frozenAfter.sourceAttachments.some((a) => a.linearAttachmentId === "att-link-1"));
|
|
622
|
+
await app.close();
|
|
623
|
+
});
|
|
624
|
+
test("NOT-422: attachment-only parked reload shows sourceAttachments pending and Resume re-freezes the manifest", async () => {
|
|
625
|
+
downloadBytes.set(FILE_URL, TARBALL_V1);
|
|
626
|
+
const app = await buildApp();
|
|
627
|
+
const id = await parkScopeWithAttachments(app);
|
|
628
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
629
|
+
const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
630
|
+
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
631
|
+
const { getTaskSnapshot, resolveHumanActionAndAdvance, TASK_SNAPSHOT_ARTIFACT_KIND } = await import("../coordinator/commands.js");
|
|
632
|
+
const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
|
|
633
|
+
const { materializeSourceAttachments } = await import("../coordinator/source-attachments.js");
|
|
634
|
+
const oldBlob = listSourceAttachments(id).find((r) => r.kind === "file").blobPath;
|
|
635
|
+
const snapshotsBefore = listArtifactsForIssue(id).filter((a) => a.kind === TASK_SNAPSHOT_ARTIFACT_KIND).length;
|
|
636
|
+
assert.equal(snapshotsBefore, 1);
|
|
637
|
+
// Linear changed only the inputs: identical contract text (and the same
|
|
638
|
+
// derived title), new bytes for the file, the link removed.
|
|
639
|
+
downloadBytes.set(FILE_URL, TARBALL_V2);
|
|
640
|
+
linearNode = {
|
|
641
|
+
...parkedNodeWith(PARKED_CONTRACT_V1, [{ id: "att-file-1", title: "repro.tar.gz", url: FILE_URL }]),
|
|
642
|
+
title: "import me",
|
|
643
|
+
};
|
|
644
|
+
const res = await app.inject({ method: "POST", url: `/api/issues/${id}/reload-source` });
|
|
645
|
+
assert.equal(res.statusCode, 200, res.body);
|
|
646
|
+
// Text byte-identical, rows swapped, the replaced blob retained for the
|
|
647
|
+
// frozen snapshot that still references it.
|
|
648
|
+
const parked = getIssue(id);
|
|
649
|
+
assert.equal(parked.title, BASE_LINEAR.title);
|
|
650
|
+
assert.equal(parked.description, PARKED_CONTRACT_V1);
|
|
651
|
+
assert.ok(parked.acceptanceCriteria.includes("[operator] Clean-machine proof attached"));
|
|
652
|
+
assert.deepEqual(listSourceAttachments(id).map((r) => r.linearAttachmentId), ["att-file-1"]);
|
|
653
|
+
assert.ok(fs.existsSync(oldBlob), "replaced blob retained while the frozen snapshot references it");
|
|
654
|
+
assert.ok(getTaskSnapshot(parked).sourceAttachments.some((a) => a.linearAttachmentId === "att-link-1"));
|
|
655
|
+
// Issue Detail reads the amended inputs as pending for the next snapshot —
|
|
656
|
+
// and only the manifest is pending.
|
|
657
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
658
|
+
assert.deepEqual(detail.pendingSnapshotChangedFields, ["sourceAttachments"]);
|
|
659
|
+
// Resume re-freezes exactly one new snapshot carrying the amended manifest.
|
|
660
|
+
const action = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision");
|
|
661
|
+
const resolved = resolveHumanActionAndAdvance(action.id, "operator", "resume");
|
|
662
|
+
assert.equal(resolved.ok, true);
|
|
663
|
+
const snapshots = listArtifactsForIssue(id).filter((a) => a.kind === TASK_SNAPSHOT_ARTIFACT_KIND);
|
|
664
|
+
assert.equal(snapshots.length, snapshotsBefore + 1);
|
|
665
|
+
const events = listWorkflowEventsForIssue(id);
|
|
666
|
+
const refreshed = events.filter((e) => e.type === "task_snapshot.refreshed");
|
|
667
|
+
assert.equal(refreshed.length, 1);
|
|
668
|
+
assert.deepEqual(JSON.parse(refreshed[0].payloadJson).changedFields, ["sourceAttachments"]);
|
|
669
|
+
const types = events.map((e) => e.type);
|
|
670
|
+
assert.ok(types.indexOf("task_snapshot.refreshed") < types.lastIndexOf("repair.started"));
|
|
671
|
+
const snap = getTaskSnapshot(getIssue(id));
|
|
672
|
+
assert.equal(snap.sourceAttachments.length, 1);
|
|
673
|
+
assert.equal(snap.sourceAttachments[0].sha256, createHash("sha256").update(TARBALL_V2).digest("hex"));
|
|
674
|
+
// The next round materializes the amended bytes — never "stored bytes are missing".
|
|
675
|
+
const worktree = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-scope-att-only-"));
|
|
676
|
+
execFileSync("git", ["init", "-q"], { cwd: worktree });
|
|
677
|
+
const materialized = materializeSourceAttachments(worktree, snap.sourceAttachments);
|
|
678
|
+
assert.equal(materialized.length, 1);
|
|
679
|
+
assert.deepEqual(fs.readFileSync(path.join(worktree, materialized[0].relativePath)), TARBALL_V2);
|
|
680
|
+
await app.close();
|
|
681
|
+
});
|
|
682
|
+
test("NOT-422: failed parked reloads preserve prior text, attachments, workflow, and the open action", async () => {
|
|
683
|
+
const { getIssue } = await import("../repository/issues.js");
|
|
684
|
+
const { getActiveWorkflowInstance, listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
685
|
+
const { getHumanAction, listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
686
|
+
async function expectIntact(id, label) {
|
|
687
|
+
const issue = getIssue(id);
|
|
688
|
+
assert.equal(issue.title, BASE_LINEAR.title, `${label}: title intact`);
|
|
689
|
+
assert.equal(issue.description, PARKED_CONTRACT_V1, `${label}: description intact`);
|
|
690
|
+
assert.ok(issue.acceptanceCriteria.includes("[operator] Clean-machine proof attached"), `${label}: criteria intact`);
|
|
691
|
+
const rows = listSourceAttachments(id);
|
|
692
|
+
assert.deepEqual(rows.map((r) => r.linearAttachmentId).sort(), ["att-file-1", "att-link-1"], `${label}: attachment rows intact`);
|
|
693
|
+
const file = rows.find((r) => r.kind === "file");
|
|
694
|
+
assert.deepEqual(fs.readFileSync(file.blobPath), TARBALL_V1, `${label}: blob bytes intact`);
|
|
695
|
+
const action = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision");
|
|
696
|
+
assert.equal(getHumanAction(action.id).status, "open", `${label}: scope action still open`);
|
|
697
|
+
assert.ok(getActiveWorkflowInstance(id), `${label}: workflow still active`);
|
|
698
|
+
assert.equal(issue.status, "needs_human", `${label}: still parked`);
|
|
699
|
+
assert.ok(!listWorkflowEventsForIssue(id).some((e) => e.type === "issue.source_reloaded"), `${label}: no reload event`);
|
|
700
|
+
}
|
|
701
|
+
// A new file whose download fails: 502, prior snapshot untouched.
|
|
702
|
+
downloadBytes.set(FILE_URL, TARBALL_V1);
|
|
703
|
+
const app = await buildApp();
|
|
704
|
+
const failedId = await parkScopeWithAttachments(app);
|
|
705
|
+
const BAD_URL = "https://uploads.linear.app/abc/missing.bin";
|
|
706
|
+
downloadBytes.set(BAD_URL, "http500");
|
|
707
|
+
linearNode = parkedNodeWith(PARKED_CONTRACT_V2, [
|
|
708
|
+
{ id: "att-file-1", title: "repro.tar.gz", url: FILE_URL },
|
|
709
|
+
{ id: "att-file-9", title: "missing.bin", url: BAD_URL },
|
|
710
|
+
]);
|
|
711
|
+
const failed = await app.inject({ method: "POST", url: `/api/issues/${failedId}/reload-source` });
|
|
712
|
+
assert.equal(failed.statusCode, 502, failed.body);
|
|
713
|
+
await expectIntact(failedId, "download failure");
|
|
714
|
+
// A malformed amended contract: actionable 400, prior snapshot untouched.
|
|
715
|
+
const badId = await parkScopeWithAttachments(app);
|
|
716
|
+
const malformed = PARKED_CONTRACT_V2.replace("feature", "teleport");
|
|
717
|
+
downloadBytes.set(FILE_URL, TARBALL_V1);
|
|
718
|
+
linearNode = parkedNodeWith(malformed, [{ id: "att-file-1", title: "repro.tar.gz", url: FILE_URL }]);
|
|
719
|
+
const bad = await app.inject({ method: "POST", url: `/api/issues/${badId}/reload-source` });
|
|
720
|
+
assert.equal(bad.statusCode, 400, bad.body);
|
|
721
|
+
assert.match(bad.json().error, /unknown execution mode/);
|
|
722
|
+
await expectIntact(badId, "contract failure");
|
|
723
|
+
await app.close();
|
|
724
|
+
});
|