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.
Files changed (34) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  2. package/bundle/server/dist/adapters/github.js +50 -0
  3. package/bundle/server/dist/adapters/github.test.js +89 -1
  4. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  5. package/bundle/server/dist/coordinator/commands.js +89 -9
  6. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  7. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  8. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  9. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  10. package/bundle/server/dist/coordinator/prompts.js +57 -2
  11. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  12. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
  13. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
  14. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  15. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  16. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  17. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  18. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  19. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  20. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  21. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  22. package/bundle/server/dist/paths.js +24 -0
  23. package/bundle/server/dist/repository/issues.js +8 -1
  24. package/bundle/server/dist/repository/issues.test.js +34 -0
  25. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  26. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  27. package/bundle/server/dist/routes/issues.js +23 -8
  28. package/bundle/server/dist/routes/issues.test.js +80 -0
  29. package/bundle/server/package.json +2 -2
  30. package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
  31. package/bundle/server/static-ui/index.html +1 -1
  32. package/bundle/shared/package.json +1 -1
  33. package/package.json +1 -1
  34. package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
@@ -10,7 +10,7 @@ import { listUsageEventsForIssue, summarizeIssueUsage } from "../repository/usag
10
10
  import { listWorkflowEventsForIssue, appendWorkflowEvent, listWorkflowInstancesForIssue, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
11
11
  import { listHumanActionsForIssue, listOpenHumanActions } from "../repository/human-actions.js";
12
12
  import { listFindingsForIssue } from "../repository/findings.js";
13
- import { abortIssueAsync, applyParkedEdit, canEditParkedIssue, capWaitForIssue, checkIssueReadiness, closeReadyIssue, parkCapWait, PARKED_EDITABLE_FIELDS } from "../coordinator/commands.js";
13
+ import { abortIssueAsync, applyParkedEdit, canEditParkedIssue, capWaitForIssue, checkIssueReadiness, closeReadyIssue, diffTaskSnapshotFromLive, parkCapWait, parkedEditableFields, parkedEditableFieldsLabel } from "../coordinator/commands.js";
14
14
  import { deleteDealerIssue } from "../coordinator/delete-issue.js";
15
15
  import { getAgent } from "../repository/agents.js";
16
16
  import { executeIssueNow, isStartable, queueStatusForIssue, refreshQueueWaitReasonForIssue, startIssueViaQueue, } from "../coordinator/admission.js";
@@ -82,6 +82,10 @@ export async function registerIssueRoutes(app) {
82
82
  const statuses = requested.length > 0
83
83
  ? requested
84
84
  : IssueStatus.options.filter((s) => s !== "closed");
85
+ // NOT-385: the direction is allowlisted — only an explicit `asc` reverses
86
+ // to oldest-first; an absent or unknown value renders newest-first
87
+ // instead of failing the list.
88
+ const direction = query.direction?.trim() === "asc" ? "asc" : "desc";
85
89
  const result = queryIssues({
86
90
  search: query.q,
87
91
  status: statuses,
@@ -89,6 +93,7 @@ export async function registerIssueRoutes(app) {
89
93
  needsAttention: query.needsAttention === "1" || query.needsAttention === "true",
90
94
  page: query.page !== undefined ? Number(query.page) : undefined,
91
95
  limit: query.limit !== undefined ? Number(query.limit) : undefined,
96
+ direction,
92
97
  });
93
98
  const openActionIssueIds = new Set(listOpenHumanActions().map((a) => a.issueId));
94
99
  return {
@@ -154,6 +159,10 @@ export async function registerIssueRoutes(app) {
154
159
  // NOT-364: durable Linear source attachments — files with safe name, size
155
160
  // and checksum plus Dealer blob path; links as labeled metadata only.
156
161
  sourceAttachments: listSourceAttachments(id),
162
+ // NOT-422: live-vs-frozen task diff — the fields a parked resume would
163
+ // re-freeze into a new task snapshot (empty = in sync, or pre-freeze).
164
+ // Issue Detail renders the pending-snapshot notice off this.
165
+ pendingSnapshotChangedFields: diffTaskSnapshotFromLive(issue),
157
166
  };
158
167
  });
159
168
  /** NOT-173: stable issue-level execution read model. The existing
@@ -348,18 +357,21 @@ export async function registerIssueRoutes(app) {
348
357
  if (getActiveWorkerSessionForIssue(id)) {
349
358
  return "Cannot edit an issue with an actively running session";
350
359
  }
351
- // NOT-185: the one active-workflow exception — parked at an open attempts_exhausted
352
- // or policy_escalation action with nothing pending/leased. A resume re-freezes the
353
- // snapshot (task fields) and the next round's profile snapshot (agent fields).
360
+ // NOT-185: the active-workflow exception — parked at an open attempts_exhausted,
361
+ // policy_escalation, or (NOT-422) product_scope_decision action with nothing
362
+ // pending/leased. A resume re-freezes the snapshot (task fields) and, at an
363
+ // attempts/policy park, the next round's profile snapshot (agent fields).
354
364
  if (getActiveWorkflowInstance(id)) {
355
365
  if (!canEditParkedIssue(fresh)) {
356
366
  return "Cannot edit an issue with an active workflow";
357
367
  }
358
368
  // Only the re-frozen inputs may change at the park: repo/base branch, the review
359
- // budget and autoMerge stay as the running workflow saw them.
360
- const blocked = Object.keys(parsed.data).filter((k) => !PARKED_EDITABLE_FIELDS.has(k));
369
+ // budget and autoMerge stay as the running workflow saw them — and at a pure
370
+ // product-scope pause the agents stay too (task text only).
371
+ const allowed = parkedEditableFields(id);
372
+ const blocked = Object.keys(parsed.data).filter((k) => !allowed.has(k));
361
373
  if (blocked.length > 0) {
362
- return `Only title, description, acceptanceCriteria, developerAgentId and reviewerAgentId can be edited while parked (got: ${blocked.join(", ")})`;
374
+ return `Only ${parkedEditableFieldsLabel(id)} can be edited while parked (got: ${blocked.join(", ")})`;
363
375
  }
364
376
  }
365
377
  return null;
@@ -453,7 +465,10 @@ export async function registerIssueRoutes(app) {
453
465
  /**
454
466
  * NOT-363: reload a Linear-sourced issue's task text from its linked ticket.
455
467
  * Only while `ready` with no active workflow or running worker — a race
456
- * with admission fails closed with 409. Replaces title + description
468
+ * with admission fails closed with 409. NOT-422 adds the parked exception:
469
+ * a `needs_human` product-scope pause (open `product_scope_decision`, no
470
+ * running session, nothing pending/leased) reloads the same way and keeps
471
+ * the action open for an explicit Resume. Replaces title + description
457
472
  * atomically, recompiles the execution contract, and swaps the acceptance
458
473
  * criteria for the freshly derived ones; repo, agents, policy fields,
459
474
  * auto-merge, queue position, and coordinator-owned fields are preserved.
@@ -735,6 +735,86 @@ test("NOT-228: limit clamps to 100 and out-of-range pages keep totals", async ()
735
735
  assert.equal(pastEnd.page, 9);
736
736
  await app.close();
737
737
  });
738
+ // NOT-385: Latest with no direction renders newest-first; the ascending
739
+ // reverse renders the same cohort oldest-first, and an unknown direction
740
+ // falls back to newest-first instead of failing.
741
+ test("NOT-385: direction defaults to newest-first, asc reverses, unknown falls back", async () => {
742
+ const app = await buildApp();
743
+ const oldIssue = await r228Seed(app, "R385 order old");
744
+ const newIssue = await r228Seed(app, "R385 order new");
745
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-08-01T00:00:00.000Z", oldIssue.id);
746
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-09-01T00:00:00.000Z", newIssue.id);
747
+ const q = encodeURIComponent("R385 order");
748
+ const newest = await r228Get(app, `?q=${q}&page=1&limit=10`);
749
+ assert.deepEqual(newest.issues.map((i) => i.id), [newIssue.id, oldIssue.id]);
750
+ const explicitDesc = await r228Get(app, `?q=${q}&page=1&limit=10&direction=desc`);
751
+ assert.deepEqual(explicitDesc.issues.map((i) => i.id), [newIssue.id, oldIssue.id]);
752
+ const oldest = await r228Get(app, `?q=${q}&page=1&limit=10&direction=asc`);
753
+ assert.deepEqual(oldest.issues.map((i) => i.id), [oldIssue.id, newIssue.id]);
754
+ assert.equal(oldest.total, newest.total);
755
+ const fallback = await r228Get(app, `?q=${q}&page=1&limit=10&direction=sideways`);
756
+ assert.deepEqual(fallback.issues.map((i) => i.id), [newIssue.id, oldIssue.id]);
757
+ await app.close();
758
+ });
759
+ // NOT-385: equal `updated_at` rows paginate without duplicates or skips in
760
+ // either direction — the rowid tie-breaker follows the timestamp direction
761
+ // before LIMIT/OFFSET applies.
762
+ test("NOT-385: equal timestamps paginate repeatably in both directions", async () => {
763
+ const app = await buildApp();
764
+ const ids = [];
765
+ for (const title of ["R385 tie one", "R385 tie two", "R385 tie three"]) {
766
+ ids.push((await r228Seed(app, title)).id);
767
+ }
768
+ for (const id of ids) {
769
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-09-01T00:00:00.000Z", id);
770
+ }
771
+ const q = encodeURIComponent("R385 tie");
772
+ const descFirst = await r228Get(app, `?q=${q}&limit=2&page=1`);
773
+ const descSecond = await r228Get(app, `?q=${q}&limit=2&page=2`);
774
+ assert.deepEqual(descFirst.issues.map((i) => i.id), [ids[2], ids[1]]);
775
+ assert.deepEqual(descSecond.issues.map((i) => i.id), [ids[0]]);
776
+ const ascFirst = await r228Get(app, `?q=${q}&limit=2&page=1&direction=asc`);
777
+ const ascSecond = await r228Get(app, `?q=${q}&limit=2&page=2&direction=asc`);
778
+ assert.deepEqual(ascFirst.issues.map((i) => i.id), [ids[0], ids[1]]);
779
+ assert.deepEqual(ascSecond.issues.map((i) => i.id), [ids[2]]);
780
+ for (const [first, second] of [[descFirst, descSecond], [ascFirst, ascSecond]]) {
781
+ assert.equal(first.total, 3);
782
+ assert.equal(first.totalPages, 2);
783
+ assert.deepEqual(new Set([...first.issues, ...second.issues].map((i) => i.id)).size, 3, "pages neither duplicate nor skip rows");
784
+ }
785
+ await app.close();
786
+ });
787
+ // NOT-385: the direction composes with every filter — the reverse renders the
788
+ // same result set, only oldest-first.
789
+ test("NOT-385: direction coexists with the search, status, repo, and attention filters", async () => {
790
+ const app = await buildApp();
791
+ const repo = "github.com/r385/filtered";
792
+ const keep = await r228Seed(app, "R385 filter keep me", { repo });
793
+ const moved = await r228Seed(app, "R385 filter moved on", { repo });
794
+ await r228Seed(app, "R385 filter other repo", { repo: "github.com/r385/elsewhere" });
795
+ transitionIssue(moved.id, "developing");
796
+ createHumanAction({
797
+ issueId: moved.id,
798
+ actionType: "final_review",
799
+ reason: "Reviewer approved",
800
+ question: "Accept?",
801
+ responseOptions: ["complete"],
802
+ });
803
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-08-01T00:00:00.000Z", keep.id);
804
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-09-01T00:00:00.000Z", moved.id);
805
+ const q = encodeURIComponent("R385 filter");
806
+ const repoParam = encodeURIComponent(repo);
807
+ const newest = await r228Get(app, `?q=${q}&repo=${repoParam}&page=1&limit=10`);
808
+ assert.deepEqual(newest.issues.map((i) => i.id), [moved.id, keep.id]);
809
+ const oldest = await r228Get(app, `?q=${q}&repo=${repoParam}&page=1&limit=10&direction=asc`);
810
+ assert.deepEqual(oldest.issues.map((i) => i.id), [keep.id, moved.id]);
811
+ assert.equal(oldest.total, newest.total);
812
+ const byStatus = await r228Get(app, `?q=${q}&status=developing&page=1&limit=10&direction=asc`);
813
+ assert.deepEqual(byStatus.issues.map((i) => i.id), [moved.id]);
814
+ const attention = await r228Get(app, `?q=${q}&needsAttention=1&page=1&limit=10&direction=asc`);
815
+ assert.deepEqual(attention.issues.map((i) => i.id), [moved.id]);
816
+ await app.close();
817
+ });
738
818
  test("NOT-217: PATCH reviewer-only edit records before/after reviewers without touching the developer", async () => {
739
819
  const app = await buildApp();
740
820
  const created = (await app.inject({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  "test": "AGENT_DEALER_SKIP_GITHUB_HEALTH=1 AGENT_DEALER_SKIP_AGENT_HEALTH=1 npm run build -w @agent-dealer/shared && AGENT_DEALER_SKIP_GITHUB_HEALTH=1 AGENT_DEALER_SKIP_AGENT_HEALTH=1 node ../../scripts/run-workspace-tests.mjs"
24
24
  },
25
25
  "dependencies": {
26
- "@agent-dealer/shared": "1.3.1",
26
+ "@agent-dealer/shared": "1.3.2",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@fastify/static": "^8.2.0",
29
29
  "@modelcontextprotocol/sdk": "^1.29.0",