agent-dealer 1.3.0 → 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/admission.test.js +115 -0
- 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 +1083 -0
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +780 -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/coordinator/worker-loop.js +1 -1
- package/bundle/server/dist/paths.js +24 -0
- package/bundle/server/dist/repository/admission-settings.js +5 -5
- package/bundle/server/dist/repository/admission-settings.test.js +71 -15
- 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/dist/routes/queue.js +1 -1
- package/bundle/server/dist/routes/queue.test.js +55 -20
- package/bundle/server/dist/runners/process-registry.js +1 -1
- 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/dist/queue-entries.d.ts +5 -4
- package/bundle/shared/dist/queue-entries.js +5 -4
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/templates/prod.env.example +5 -2
- package/bundle/server/static-ui/assets/index-Bm74Vq_X.js +0 -64
|
@@ -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
|
+
});
|
|
@@ -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,
|
|
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
|
|
352
|
-
// or
|
|
353
|
-
//
|
|
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
|
-
|
|
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
|
|
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.
|
|
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({
|
|
@@ -17,7 +17,7 @@ export async function registerQueueRoutes(app) {
|
|
|
17
17
|
return getAdmissionStatus();
|
|
18
18
|
});
|
|
19
19
|
/**
|
|
20
|
-
* NOT-215: operator-chosen active-issue limit (1–
|
|
20
|
+
* NOT-215: operator-chosen active-issue limit (1–5, never above the effective
|
|
21
21
|
* worker/spawn ceiling). Persisted in the DB — survives page reload and server
|
|
22
22
|
* restart. Raising fills free slots on the next tick; lowering only pauses new
|
|
23
23
|
* admissions, never interrupts running work.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// packages/server/src/routes/queue.test.ts
|
|
2
|
-
import { test, before, beforeEach } from "node:test";
|
|
2
|
+
import { test, before, beforeEach, afterEach } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import os from "node:os";
|
|
@@ -12,6 +12,10 @@ const { createIssue } = await import("../repository/issues.js");
|
|
|
12
12
|
const { registerQueueRoutes } = await import("./queue.js");
|
|
13
13
|
const { startWorkflow } = await import("../coordinator/commands.js");
|
|
14
14
|
before(() => migrate());
|
|
15
|
+
const savedEnv = {
|
|
16
|
+
MAX_COORDINATOR_CONCURRENCY: process.env.MAX_COORDINATOR_CONCURRENCY,
|
|
17
|
+
MAX_CONCURRENT_RUNS: process.env.MAX_CONCURRENT_RUNS,
|
|
18
|
+
};
|
|
15
19
|
beforeEach(() => {
|
|
16
20
|
getDb().exec(`
|
|
17
21
|
DELETE FROM queue_entries;
|
|
@@ -24,6 +28,17 @@ beforeEach(() => {
|
|
|
24
28
|
`);
|
|
25
29
|
// NOT-215: the persisted concurrency setting must not leak between tests.
|
|
26
30
|
getDb().prepare("DELETE FROM intake_settings WHERE key = 'admission.maxActiveIssues'").run();
|
|
31
|
+
// NOT-378: ceiling assertions assume no environment overrides.
|
|
32
|
+
delete process.env.MAX_COORDINATOR_CONCURRENCY;
|
|
33
|
+
delete process.env.MAX_CONCURRENT_RUNS;
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
37
|
+
if (value === undefined)
|
|
38
|
+
delete process.env[key];
|
|
39
|
+
else
|
|
40
|
+
process.env[key] = value;
|
|
41
|
+
}
|
|
27
42
|
});
|
|
28
43
|
async function app() {
|
|
29
44
|
const f = Fastify();
|
|
@@ -87,26 +102,28 @@ test("NOT-215: GET /api/queue/status reports the truthful admission read model",
|
|
|
87
102
|
waiting: 0,
|
|
88
103
|
limit: 1,
|
|
89
104
|
maxActiveIssues: 1,
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
// NOT-378: default five-process ceiling, options 1–5.
|
|
106
|
+
ceiling: 5,
|
|
107
|
+
options: [1, 2, 3, 4, 5],
|
|
92
108
|
overCap: false,
|
|
93
109
|
});
|
|
94
110
|
await f.close();
|
|
95
111
|
});
|
|
96
|
-
test("NOT-215: PUT /api/queue/settings persists 1..
|
|
112
|
+
test("NOT-215: PUT /api/queue/settings persists 1..5 and the status reflects it", async () => {
|
|
97
113
|
const f = await app();
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
for (const value of [1, 2, 3, 4, 5]) {
|
|
115
|
+
const put = await f.inject({
|
|
116
|
+
method: "PUT",
|
|
117
|
+
url: "/api/queue/settings",
|
|
118
|
+
payload: { maxActiveIssues: value },
|
|
119
|
+
});
|
|
120
|
+
assert.equal(put.statusCode, 200, `accepts ${value}`);
|
|
121
|
+
assert.equal(put.json().maxActiveIssues, value);
|
|
122
|
+
assert.equal(put.json().limit, value);
|
|
123
|
+
}
|
|
107
124
|
const status = await f.inject({ method: "GET", url: "/api/queue/status" });
|
|
108
|
-
assert.equal(status.json().maxActiveIssues,
|
|
109
|
-
assert.equal(status.json().limit,
|
|
125
|
+
assert.equal(status.json().maxActiveIssues, 5);
|
|
126
|
+
assert.equal(status.json().limit, 5);
|
|
110
127
|
await f.close();
|
|
111
128
|
});
|
|
112
129
|
test("NOT-215: the persisted limit survives a server restart (fresh app, same DB)", async () => {
|
|
@@ -114,30 +131,48 @@ test("NOT-215: the persisted limit survives a server restart (fresh app, same DB
|
|
|
114
131
|
const put = await f.inject({
|
|
115
132
|
method: "PUT",
|
|
116
133
|
url: "/api/queue/settings",
|
|
117
|
-
payload: { maxActiveIssues:
|
|
134
|
+
payload: { maxActiveIssues: 5 },
|
|
118
135
|
});
|
|
119
136
|
assert.equal(put.statusCode, 200);
|
|
120
137
|
await f.close();
|
|
121
138
|
const restarted = await app();
|
|
122
139
|
const status = await restarted.inject({ method: "GET", url: "/api/queue/status" });
|
|
123
|
-
assert.equal(status.json().maxActiveIssues,
|
|
124
|
-
assert.equal(status.json().limit,
|
|
140
|
+
assert.equal(status.json().maxActiveIssues, 5);
|
|
141
|
+
assert.equal(status.json().limit, 5);
|
|
125
142
|
await restarted.close();
|
|
126
143
|
});
|
|
127
|
-
test("NOT-215: PUT /api/queue/settings rejects values outside 1..
|
|
144
|
+
test("NOT-215: PUT /api/queue/settings rejects values outside 1..5", async () => {
|
|
128
145
|
const f = await app();
|
|
129
146
|
for (const payload of [
|
|
130
147
|
{ maxActiveIssues: 0 },
|
|
131
|
-
{ maxActiveIssues:
|
|
148
|
+
{ maxActiveIssues: 6 },
|
|
149
|
+
{ maxActiveIssues: 99 },
|
|
132
150
|
{ maxActiveIssues: 1.5 },
|
|
133
151
|
{ maxActiveIssues: "x" },
|
|
134
152
|
{},
|
|
135
153
|
]) {
|
|
136
154
|
const res = await f.inject({ method: "PUT", url: "/api/queue/settings", payload });
|
|
137
155
|
assert.equal(res.statusCode, 400, `rejects ${JSON.stringify(payload)}`);
|
|
156
|
+
assert.equal(typeof res.json().error, "string");
|
|
138
157
|
}
|
|
139
158
|
// A rejected write leaves the stored value alone.
|
|
140
159
|
const status = await f.inject({ method: "GET", url: "/api/queue/status" });
|
|
141
160
|
assert.equal(status.json().maxActiveIssues, 1);
|
|
142
161
|
await f.close();
|
|
143
162
|
});
|
|
163
|
+
test("NOT-378: PUT /api/queue/settings rejects a value above the effective ceiling", async () => {
|
|
164
|
+
process.env.MAX_COORDINATOR_CONCURRENCY = "3";
|
|
165
|
+
const f = await app();
|
|
166
|
+
const res = await f.inject({
|
|
167
|
+
method: "PUT",
|
|
168
|
+
url: "/api/queue/settings",
|
|
169
|
+
payload: { maxActiveIssues: 4 },
|
|
170
|
+
});
|
|
171
|
+
assert.equal(res.statusCode, 400);
|
|
172
|
+
assert.equal(res.json().error, "maxActiveIssues 4 exceeds the effective worker/spawn ceiling of 3");
|
|
173
|
+
const status = await f.inject({ method: "GET", url: "/api/queue/status" });
|
|
174
|
+
assert.equal(status.json().maxActiveIssues, 1, "a rejected write leaves the stored value alone");
|
|
175
|
+
assert.equal(status.json().ceiling, 3);
|
|
176
|
+
assert.deepEqual(status.json().options, [1, 2, 3]);
|
|
177
|
+
await f.close();
|
|
178
|
+
});
|
|
@@ -3,7 +3,7 @@ const activeLogPaths = new Map();
|
|
|
3
3
|
let activeSpawnCount = 0;
|
|
4
4
|
const spawnWaiters = [];
|
|
5
5
|
export function maxConcurrentSpawns() {
|
|
6
|
-
return Number(process.env.MAX_CONCURRENT_RUNS ??
|
|
6
|
+
return Number(process.env.MAX_CONCURRENT_RUNS ?? 5);
|
|
7
7
|
}
|
|
8
8
|
export async function acquireSpawnSlot() {
|
|
9
9
|
const max = maxConcurrentSpawns();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-dealer/server",
|
|
3
|
-
"version": "1.3.
|
|
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.
|
|
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",
|