agent-dealer 1.2.1 → 1.2.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 (43) hide show
  1. package/bundle/server/dist/adapters/linear-inbox.js +9 -5
  2. package/bundle/server/dist/capacity/adapter.js +2 -0
  3. package/bundle/server/dist/capacity/claude-events.js +18 -0
  4. package/bundle/server/dist/capacity/claude-events.test.js +7 -0
  5. package/bundle/server/dist/capacity/codex-app-server.js +100 -10
  6. package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
  7. package/bundle/server/dist/capacity/cursor-individual-credentials.js +238 -0
  8. package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +185 -0
  9. package/bundle/server/dist/capacity/cursor-individual.js +970 -0
  10. package/bundle/server/dist/capacity/cursor-individual.test.js +815 -0
  11. package/bundle/server/dist/capacity/muse.js +94 -43
  12. package/bundle/server/dist/capacity/muse.test.js +95 -13
  13. package/bundle/server/dist/capacity/service.js +1 -0
  14. package/bundle/server/dist/db/index.js +30 -0
  15. package/bundle/server/dist/db/schema.sql +27 -0
  16. package/bundle/server/dist/index.js +2 -0
  17. package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
  18. package/bundle/server/dist/repository/repository-mappings.js +46 -0
  19. package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
  20. package/bundle/server/dist/repository/runtime-capacity.js +8 -3
  21. package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
  22. package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
  23. package/bundle/server/dist/routes/index.js +32 -0
  24. package/bundle/server/dist/routes/intake-linear.test.js +55 -1
  25. package/bundle/server/dist/routes/repository-mappings.js +22 -0
  26. package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
  27. package/bundle/server/dist/routes/runtime-capacity.test.js +72 -2
  28. package/bundle/server/package.json +2 -2
  29. package/bundle/server/static-ui/assets/index-0GDYfbaM.js +60 -0
  30. package/bundle/server/static-ui/assets/index-DUYzboWh.css +1 -0
  31. package/bundle/server/static-ui/index.html +2 -2
  32. package/bundle/shared/dist/index.d.ts +6 -6
  33. package/bundle/shared/dist/linear-intake.d.ts +71 -0
  34. package/bundle/shared/dist/linear-intake.js +90 -0
  35. package/bundle/shared/dist/linear-intake.test.js +80 -1
  36. package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
  37. package/bundle/shared/dist/runtime-capacity.js +60 -0
  38. package/bundle/shared/dist/runtime-capacity.test.js +1 -0
  39. package/bundle/shared/package.json +1 -1
  40. package/dist/doctor.js +6 -0
  41. package/package.json +1 -1
  42. package/bundle/server/static-ui/assets/index-ChnI0F02.css +0 -1
  43. package/bundle/server/static-ui/assets/index-CyB1573Q.js +0 -60
@@ -0,0 +1,46 @@
1
+ import { normalizeRepositoryMappings } from "@agent-dealer/shared";
2
+ import { getDb } from "../db/index.js";
3
+ /** Storage key inside the existing `intake_settings` table — no migration needed. */
4
+ export const REPOSITORY_MAPPINGS_KEY = "linear.repositoryMappings";
5
+ function readRaw() {
6
+ const row = getDb()
7
+ .prepare("SELECT value_json FROM intake_settings WHERE key = ?")
8
+ .get(REPOSITORY_MAPPINGS_KEY);
9
+ if (!row)
10
+ return [];
11
+ try {
12
+ return JSON.parse(row.value_json);
13
+ }
14
+ catch {
15
+ return [];
16
+ }
17
+ }
18
+ /**
19
+ * NOT-260: list saved label → repository mappings. A fresh install returns
20
+ * `[]`; a corrupt row reads as `[]` rather than throwing.
21
+ */
22
+ export function listRepositoryMappings() {
23
+ const raw = readRaw();
24
+ const rows = Array.isArray(raw) ? raw : raw?.mappings;
25
+ if (!Array.isArray(rows))
26
+ return [];
27
+ try {
28
+ return normalizeRepositoryMappings({ mappings: rows });
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
34
+ /**
35
+ * NOT-260: atomically replace all mappings. Normalizes labels/repositories
36
+ * before writing; editing the repository for an existing unique label
37
+ * overwrites its prior value. Empty labels, invalid repositories, duplicate
38
+ * normalized labels, or >100 rows throw without changing the last valid array.
39
+ */
40
+ export function replaceRepositoryMappings(input) {
41
+ const normalized = normalizeRepositoryMappings(input);
42
+ getDb()
43
+ .prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json")
44
+ .run(REPOSITORY_MAPPINGS_KEY, JSON.stringify(normalized));
45
+ return normalized;
46
+ }
@@ -0,0 +1,83 @@
1
+ // NOT-260: label → repository mapping persistence — JSON under
2
+ // `linear.repositoryMappings` in intake_settings, atomic replace, overwrite,
3
+ // duplicate rejection without touching the last valid array.
4
+ import { test, before } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-repo-mappings-"));
10
+ const { migrate } = await import("../db/index.js");
11
+ const { listRepositoryMappings, replaceRepositoryMappings, } = await import("./repository-mappings.js");
12
+ before(() => {
13
+ migrate();
14
+ });
15
+ test("a fresh install returns an empty mapping array", () => {
16
+ assert.deepEqual(listRepositoryMappings(), []);
17
+ });
18
+ test("saving normalizes label and repository", () => {
19
+ const saved = replaceRepositoryMappings({
20
+ mappings: [{ label: " agent-dealer ", repository: "not-so-fat/agent-dealer" }],
21
+ });
22
+ assert.deepEqual(saved, [
23
+ { label: "agent-dealer", repository: "github.com/not-so-fat/agent-dealer" },
24
+ ]);
25
+ assert.deepEqual(listRepositoryMappings(), saved);
26
+ });
27
+ test("editing the repository for an existing unique label overwrites it", () => {
28
+ replaceRepositoryMappings({
29
+ mappings: [{ label: "agent-dealer", repository: "not-so-fat/agent-dealer" }],
30
+ });
31
+ const saved = replaceRepositoryMappings({
32
+ mappings: [{ label: "agent-dealer", repository: "not-so-fat/other" }],
33
+ });
34
+ assert.deepEqual(saved, [
35
+ { label: "agent-dealer", repository: "github.com/not-so-fat/other" },
36
+ ]);
37
+ const listed = listRepositoryMappings();
38
+ assert.equal(listed.length, 1);
39
+ assert.deepEqual(listed[0], {
40
+ label: "agent-dealer",
41
+ repository: "github.com/not-so-fat/other",
42
+ });
43
+ });
44
+ test("duplicate normalized labels fail without changing the last valid array", () => {
45
+ replaceRepositoryMappings({
46
+ mappings: [{ label: "agent-dealer", repository: "not-so-fat/other" }],
47
+ });
48
+ assert.throws(() => replaceRepositoryMappings({
49
+ mappings: [
50
+ { label: "agent-dealer", repository: "not-so-fat/agent-dealer" },
51
+ { label: " AGENT-dealer ", repository: "not-so-fat/other" },
52
+ ],
53
+ }), /Duplicate repository mapping/);
54
+ assert.deepEqual(listRepositoryMappings(), [
55
+ { label: "agent-dealer", repository: "github.com/not-so-fat/other" },
56
+ ]);
57
+ });
58
+ test("empty labels, invalid repositories, and 101 rows fail atomically", () => {
59
+ replaceRepositoryMappings({
60
+ mappings: [{ label: "agent-dealer", repository: "not-so-fat/other" }],
61
+ });
62
+ const beforeRows = listRepositoryMappings();
63
+ assert.throws(() => replaceRepositoryMappings({ mappings: [{ label: " ", repository: "a/b" }] }), /must not be empty/);
64
+ assert.deepEqual(listRepositoryMappings(), beforeRows);
65
+ assert.throws(() => replaceRepositoryMappings({ mappings: [{ label: "ok", repository: "not a repo!!" }] }), /Invalid repository/);
66
+ assert.deepEqual(listRepositoryMappings(), beforeRows);
67
+ const many = Array.from({ length: 101 }, (_, i) => ({ label: `l${i}`, repository: "a/b" }));
68
+ assert.throws(() => replaceRepositoryMappings({ mappings: many }), /At most 100/);
69
+ assert.deepEqual(listRepositoryMappings(), beforeRows);
70
+ });
71
+ test("replacement is whole-array: omitted labels disappear, shared targets stay", () => {
72
+ replaceRepositoryMappings({
73
+ mappings: [
74
+ { label: "agent-dealer", repository: "not-so-fat/agent-dealer" },
75
+ { label: "dealer", repository: "not-so-fat/agent-dealer" },
76
+ ],
77
+ });
78
+ const saved = replaceRepositoryMappings({
79
+ mappings: [{ label: "dealer", repository: "not-so-fat/agent-dealer" }],
80
+ });
81
+ assert.deepEqual(saved, [{ label: "dealer", repository: "github.com/not-so-fat/agent-dealer" }]);
82
+ assert.deepEqual(listRepositoryMappings(), saved);
83
+ });
@@ -25,6 +25,7 @@ function rowToSnapshot(row) {
25
25
  expiresAt: row.expires_at,
26
26
  source: row.source,
27
27
  unavailableReason: row.unavailable_reason,
28
+ criticalRole: row.critical_role,
28
29
  };
29
30
  }
30
31
  /**
@@ -37,12 +38,14 @@ export function recordCapacitySnapshots(runtime, windows) {
37
38
  INSERT INTO runtime_capacity_snapshots (
38
39
  runtime, window_key, provider_bucket, duration_minutes, display_label,
39
40
  used_value, used_unit, remaining_percent, reset_at, observed_at,
40
- fresh_until, expires_at, source, unavailable_reason, evidence_ref
41
+ fresh_until, expires_at, source, unavailable_reason, evidence_ref,
42
+ critical_role
41
43
  )
42
44
  VALUES (
43
45
  @runtime, @window_key, @provider_bucket, @duration_minutes, @display_label,
44
46
  @used_value, @used_unit, @remaining_percent, @reset_at, @observed_at,
45
- @fresh_until, @expires_at, @source, @unavailable_reason, @evidence_ref
47
+ @fresh_until, @expires_at, @source, @unavailable_reason, @evidence_ref,
48
+ @critical_role
46
49
  )
47
50
  ON CONFLICT(runtime, window_key) DO UPDATE SET
48
51
  provider_bucket = excluded.provider_bucket,
@@ -57,7 +60,8 @@ export function recordCapacitySnapshots(runtime, windows) {
57
60
  expires_at = excluded.expires_at,
58
61
  source = excluded.source,
59
62
  unavailable_reason = excluded.unavailable_reason,
60
- evidence_ref = excluded.evidence_ref
63
+ evidence_ref = excluded.evidence_ref,
64
+ critical_role = excluded.critical_role
61
65
  `);
62
66
  const run = getDb().transaction((rows) => {
63
67
  for (const w of rows) {
@@ -77,6 +81,7 @@ export function recordCapacitySnapshots(runtime, windows) {
77
81
  source: w.source,
78
82
  unavailable_reason: w.unavailableReason ?? null,
79
83
  evidence_ref: w.evidenceRef ?? null,
84
+ critical_role: w.criticalRole ?? null,
80
85
  });
81
86
  }
82
87
  });
@@ -43,6 +43,7 @@ test("upserts per (runtime, window) and keeps sibling windows on partial writes"
43
43
  resetAt: new Date(Date.now() + 1800_000).toISOString(),
44
44
  observedAt: nowIso(),
45
45
  source: "supported_protocol",
46
+ criticalRole: "five_hour",
46
47
  },
47
48
  ]);
48
49
  // Partial re-observation updates one window and leaves the sibling alone.
@@ -63,6 +64,9 @@ test("upserts per (runtime, window) and keeps sibling windows on partial writes"
63
64
  assert.equal(windows.length, 2);
64
65
  assert.equal(windows.find((w) => w.windowKey === "weekly")?.remainingPercent, 60);
65
66
  assert.equal(windows.find((w) => w.windowKey === "five_hour")?.remainingPercent, 50);
67
+ // criticalRole round-trips through the DB; a window that never set it reads null.
68
+ assert.equal(windows.find((w) => w.windowKey === "five_hour")?.criticalRole, "five_hour");
69
+ assert.equal(windows.find((w) => w.windowKey === "weekly")?.criticalRole, null);
66
70
  assert.deepEqual(capacityRuntimes(), ["claude_code"]);
67
71
  assert.equal(listAllCapacitySnapshots().length, 2);
68
72
  });
@@ -0,0 +1,80 @@
1
+ // packages/server/src/routes/cursor-individual-billing.test.ts
2
+ //
3
+ // NOT-250: GET /api/cursor-individual-billing serves the experimental opt-in
4
+ // surface — disabled reads `enabled: false` N/A with no credential or
5
+ // dashboard access, and responses validate against the shared contract with
6
+ // no secret material.
7
+ import { test, beforeEach, afterEach } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import Fastify from "fastify";
13
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cap-indiv-route-"));
14
+ process.env.AGENT_DEALER_SKIP_GITHUB_HEALTH = "1";
15
+ process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
16
+ // Never poll providers from route tests: on-demand refreshes are covered
17
+ // against mocks in the adapter tests.
18
+ process.env.AGENT_DEALER_CODEX_CAPACITY_REFRESH = "off";
19
+ process.env.AGENT_DEALER_CURSOR_TEAM_CAPACITY_REFRESH = "off";
20
+ process.env.AGENT_DEALER_CURSOR_INDIVIDUAL_REFRESH = "off";
21
+ const { migrate } = await import("../db/index.js");
22
+ const { registerRoutes } = await import("./index.js");
23
+ const { CursorIndividualBilling } = await import("@agent-dealer/shared");
24
+ const { CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV } = await import("../capacity/cursor-individual-credentials.js");
25
+ migrate();
26
+ let savedOptIn;
27
+ let savedCredFile;
28
+ beforeEach(() => {
29
+ savedOptIn = process.env.AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY;
30
+ savedCredFile = process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
31
+ delete process.env.AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY;
32
+ delete process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
33
+ });
34
+ afterEach(() => {
35
+ if (savedOptIn === undefined)
36
+ delete process.env.AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY;
37
+ else
38
+ process.env.AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY = savedOptIn;
39
+ if (savedCredFile === undefined)
40
+ delete process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
41
+ else
42
+ process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV] = savedCredFile;
43
+ });
44
+ async function buildApp() {
45
+ const app = Fastify();
46
+ await registerRoutes(app);
47
+ return app;
48
+ }
49
+ test("disabled reads enabled:false N/A without credential or endpoint access", async () => {
50
+ // A credential file exists — the disabled route must still never read it
51
+ // (and there is no mock HTTP anyway: any attempt would throw or hang).
52
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-indiv-route-creds-"));
53
+ try {
54
+ const file = path.join(dir, "auth.json");
55
+ fs.writeFileSync(file, JSON.stringify({ token: "route-secret-must-not-leak" }));
56
+ process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV] = file;
57
+ const app = await buildApp();
58
+ const res = await app.inject({ method: "GET", url: "/api/cursor-individual-billing" });
59
+ assert.equal(res.statusCode, 200);
60
+ const body = CursorIndividualBilling.parse(res.json());
61
+ assert.equal(body.enabled, false);
62
+ assert.equal(body.configured, false);
63
+ assert.equal(body.source, "unavailable");
64
+ assert.equal(body.unavailableReason, "missing");
65
+ assert.equal(body.remainingPercent, null);
66
+ assert.ok(!JSON.stringify(res.json()).includes("route-secret-must-not-leak"), "no credential material reaches the browser");
67
+ }
68
+ finally {
69
+ fs.rmSync(dir, { recursive: true, force: true });
70
+ }
71
+ });
72
+ test("opt-in without stored data reads enabled N/A (missing)", async () => {
73
+ process.env.AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY = "experimental";
74
+ const app = await buildApp();
75
+ const res = await app.inject({ method: "GET", url: "/api/cursor-individual-billing" });
76
+ assert.equal(res.statusCode, 200);
77
+ const body = CursorIndividualBilling.parse(res.json());
78
+ assert.equal(body.enabled, true);
79
+ assert.equal(body.unavailableReason, "missing");
80
+ });
@@ -10,6 +10,7 @@ import { configuredCapacityRuntimes, getRuntimeCapacitySnapshot } from "../capac
10
10
  import { maybeRefreshMuseCapacityFromServe } from "../capacity/muse.js";
11
11
  import { refreshCodexCapacityIfStale } from "../capacity/codex-app-server.js";
12
12
  import { getCursorTeamBillingSnapshot, refreshCursorTeamBillingIfStale, } from "../capacity/cursor-team.js";
13
+ import { getCursorIndividualBillingSnapshot, refreshCursorIndividualBillingIfStale, refreshCursorIndividualCapacityIfStale, } from "../capacity/cursor-individual.js";
13
14
  async function resolveDeckName(deckId) {
14
15
  if (!deckId)
15
16
  return null;
@@ -90,6 +91,15 @@ export async function registerRoutes(app) {
90
91
  // A refresh failure must never break the read — stored snapshots still
91
92
  // served below with their explicit N/A reasons.
92
93
  }
94
+ try {
95
+ // NOT-250: opted-in experimental Individual billing-cycle window for
96
+ // `cursor_local`. Disabled is a strict no-op (no credential, no HTTP);
97
+ // failures never break the read below.
98
+ await refreshCursorIndividualCapacityIfStale();
99
+ }
100
+ catch {
101
+ // Stored snapshots still serve below with their explicit N/A reasons.
102
+ }
93
103
  return getRuntimeCapacitySnapshot();
94
104
  });
95
105
  // NOT-249: team-level Cursor billing from the official Admin API — separate
@@ -110,6 +120,28 @@ export async function registerRoutes(app) {
110
120
  }
111
121
  return getCursorTeamBillingSnapshot();
112
122
  });
123
+ // NOT-250: monthly/billing-cycle capacity for Cursor Individual accounts
124
+ // via the opt-in EXPERIMENTAL dashboard adapter (undocumented endpoints,
125
+ // local-login credential — no supported contract, no support guarantee).
126
+ // Disabled by default: without `AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY=
127
+ // experimental` this reads `enabled: false` with no credential or endpoint
128
+ // access. Normalized snapshots only; the credential never leaves the
129
+ // server. On-demand stale refresh mirrors the Team path: a missing/stale
130
+ // stored snapshot triggers one bounded dashboard poll (single-flight,
131
+ // never health rows, never throws); fresh snapshots serve stored data with
132
+ // no HTTP, and `AGENT_DEALER_CURSOR_INDIVIDUAL_REFRESH=off` disables it.
133
+ // The browser must surface the `experimental_api` source with the local
134
+ // setting that disables it (see CursorIndividualBillingCard).
135
+ app.get("/api/cursor-individual-billing", async () => {
136
+ try {
137
+ await refreshCursorIndividualBillingIfStale();
138
+ }
139
+ catch {
140
+ // A refresh failure must never break the read — the stored snapshot
141
+ // still serves below with its explicit N/A reason.
142
+ }
143
+ return getCursorIndividualBillingSnapshot();
144
+ });
113
145
  app.get("/api/agents", async () => {
114
146
  const agents = await listAgentsWithHealth(listAgents());
115
147
  return {
@@ -33,7 +33,13 @@ before(() => {
33
33
  globalThis.fetch = (async (_url, init) => {
34
34
  const body = JSON.parse(String(init?.body ?? "{}"));
35
35
  const payload = typeof body.variables?.id === "string"
36
- ? { data: { issue: LIST_NODES.find((n) => n.identifier === "NOT-242") ?? null } }
36
+ ? {
37
+ data: {
38
+ issue: LIST_NODES.find((n) => n.identifier === body.variables?.id) ??
39
+ LIST_NODES.find((n) => n.identifier === "NOT-242") ??
40
+ null,
41
+ },
42
+ }
37
43
  : {
38
44
  data: {
39
45
  issues: { nodes: LIST_NODES, pageInfo: { hasNextPage: false, endCursor: null } },
@@ -89,3 +95,51 @@ test("GET /api/intake/linear/lookup rejects an unparseable ref without calling L
89
95
  const res = await app.inject({ method: "GET", url: "/api/intake/linear/lookup?q=not%20a%20ticket" });
90
96
  assert.equal(res.statusCode, 400);
91
97
  });
98
+ // NOT-260: a configured mapping prefills list candidates and direct lookups.
99
+ test("a mapped Linear label resolves in the candidate list with repo: fallback intact", async () => {
100
+ const { replaceRepositoryMappings } = await import("../repository/repository-mappings.js");
101
+ replaceRepositoryMappings({
102
+ mappings: [{ label: "agent-dealer", repository: "not-so-fat/mapped" }],
103
+ });
104
+ try {
105
+ const app = await buildApp();
106
+ const res = await app.inject({ method: "GET", url: "/api/intake/linear" });
107
+ assert.equal(res.statusCode, 200);
108
+ const json = res.json();
109
+ // NOT-243 carries the plain `agent-dealer` label — now mapped.
110
+ const mapped = json.candidates.find((c) => c.identifier === "NOT-243");
111
+ assert.equal(mapped?.repoResolution?.status, "resolved");
112
+ assert.equal(mapped?.repoResolution?.repository, "github.com/not-so-fat/mapped");
113
+ assert.equal(mapped?.repoResolution?.sourceLabel, "agent-dealer");
114
+ // Raw Linear labels stay on the candidate.
115
+ assert.deepEqual(mapped?.labels, ["agent-dealer"]);
116
+ // NOT-242 has no mapping match — legacy repo: fallback still resolves.
117
+ const legacy = json.candidates.find((c) => c.identifier === "NOT-242");
118
+ assert.equal(legacy?.repoResolution?.status, "resolved");
119
+ assert.equal(legacy?.repoResolution?.repository, "github.com/not-so-fat/agent-dealer");
120
+ assert.equal(legacy?.repoResolution?.sourceLabel, "repo:github.com/not-so-fat/agent-dealer");
121
+ }
122
+ finally {
123
+ replaceRepositoryMappings({ mappings: [] });
124
+ }
125
+ });
126
+ test("a mapped Linear label resolves in direct lookup", async () => {
127
+ const { replaceRepositoryMappings } = await import("../repository/repository-mappings.js");
128
+ replaceRepositoryMappings({
129
+ mappings: [{ label: "agent-dealer", repository: "not-so-fat/mapped" }],
130
+ });
131
+ try {
132
+ const app = await buildApp();
133
+ const res = await app.inject({ method: "GET", url: "/api/intake/linear/lookup?q=NOT-243" });
134
+ assert.equal(res.statusCode, 200);
135
+ const json = res.json();
136
+ assert.equal(json.candidate.identifier, "NOT-243");
137
+ assert.equal(json.candidate.repoResolution?.status, "resolved");
138
+ assert.equal(json.candidate.repoResolution?.repository, "github.com/not-so-fat/mapped");
139
+ assert.equal(json.candidate.repoResolution?.sourceLabel, "agent-dealer");
140
+ assert.deepEqual(json.candidate.labels, ["agent-dealer"]);
141
+ }
142
+ finally {
143
+ replaceRepositoryMappings({ mappings: [] });
144
+ }
145
+ });
@@ -0,0 +1,22 @@
1
+ import { listRepositoryMappings, replaceRepositoryMappings, } from "../repository/repository-mappings.js";
2
+ /** NOT-260: label → repository mapping settings (inline editor in New issue). */
3
+ export async function registerRepositoryMappingsRoutes(app) {
4
+ app.get("/api/settings/repository-mappings", async () => ({
5
+ mappings: listRepositoryMappings(),
6
+ }));
7
+ app.put("/api/settings/repository-mappings", async (req, reply) => {
8
+ const body = (req.body ?? {});
9
+ if (!body || !Array.isArray(body.mappings)) {
10
+ return reply.status(400).send({ error: "Body must be { mappings: [...] }" });
11
+ }
12
+ try {
13
+ const mappings = replaceRepositoryMappings({
14
+ mappings: body.mappings,
15
+ });
16
+ return { mappings };
17
+ }
18
+ catch (e) {
19
+ return reply.status(400).send({ error: e instanceof Error ? e.message : String(e) });
20
+ }
21
+ });
22
+ }
@@ -0,0 +1,110 @@
1
+ // NOT-260: GET/PUT /api/settings/repository-mappings — normalized round
2
+ // trip plus HTTP 400 with a readable error that preserves the last valid array.
3
+ import { test, before } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import Fastify from "fastify";
9
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-repo-mappings-routes-"));
10
+ const { migrate } = await import("../db/index.js");
11
+ const { registerRepositoryMappingsRoutes } = await import("./repository-mappings.js");
12
+ before(() => {
13
+ migrate();
14
+ });
15
+ async function buildApp() {
16
+ const app = Fastify();
17
+ await registerRepositoryMappingsRoutes(app);
18
+ return app;
19
+ }
20
+ test("GET returns { mappings: [] } on a fresh install", async () => {
21
+ const app = await buildApp();
22
+ const res = await app.inject({ method: "GET", url: "/api/settings/repository-mappings" });
23
+ assert.equal(res.statusCode, 200);
24
+ assert.deepEqual(res.json(), { mappings: [] });
25
+ });
26
+ test("PUT persists and returns normalized mappings", async () => {
27
+ const app = await buildApp();
28
+ const res = await app.inject({
29
+ method: "PUT",
30
+ url: "/api/settings/repository-mappings",
31
+ payload: { mappings: [{ label: "agent-dealer", repository: "not-so-fat/agent-dealer" }] },
32
+ });
33
+ assert.equal(res.statusCode, 200);
34
+ assert.deepEqual(res.json(), {
35
+ mappings: [{ label: "agent-dealer", repository: "github.com/not-so-fat/agent-dealer" }],
36
+ });
37
+ const get = await app.inject({ method: "GET", url: "/api/settings/repository-mappings" });
38
+ assert.deepEqual(get.json(), {
39
+ mappings: [{ label: "agent-dealer", repository: "github.com/not-so-fat/agent-dealer" }],
40
+ });
41
+ });
42
+ test("PUT editing the repository overwrites the label's value", async () => {
43
+ const app = await buildApp();
44
+ await app.inject({
45
+ method: "PUT",
46
+ url: "/api/settings/repository-mappings",
47
+ payload: { mappings: [{ label: "agent-dealer", repository: "not-so-fat/agent-dealer" }] },
48
+ });
49
+ const res = await app.inject({
50
+ method: "PUT",
51
+ url: "/api/settings/repository-mappings",
52
+ payload: { mappings: [{ label: "agent-dealer", repository: "not-so-fat/other" }] },
53
+ });
54
+ assert.equal(res.statusCode, 200);
55
+ assert.deepEqual(res.json(), {
56
+ mappings: [{ label: "agent-dealer", repository: "github.com/not-so-fat/other" }],
57
+ });
58
+ });
59
+ test("PUT with duplicate normalized labels returns 400 and preserves the array", async () => {
60
+ const app = await buildApp();
61
+ await app.inject({
62
+ method: "PUT",
63
+ url: "/api/settings/repository-mappings",
64
+ payload: { mappings: [{ label: "agent-dealer", repository: "not-so-fat/other" }] },
65
+ });
66
+ const res = await app.inject({
67
+ method: "PUT",
68
+ url: "/api/settings/repository-mappings",
69
+ payload: {
70
+ mappings: [
71
+ { label: "agent-dealer", repository: "not-so-fat/agent-dealer" },
72
+ { label: " AGENT-DEALER ", repository: "not-so-fat/other" },
73
+ ],
74
+ },
75
+ });
76
+ assert.equal(res.statusCode, 400);
77
+ const body = res.json();
78
+ assert.ok(body.error && body.error.length > 0, "readable error");
79
+ const get = await app.inject({ method: "GET", url: "/api/settings/repository-mappings" });
80
+ assert.deepEqual(get.json(), {
81
+ mappings: [{ label: "agent-dealer", repository: "github.com/not-so-fat/other" }],
82
+ });
83
+ });
84
+ test("PUT with empty labels, invalid repos, or 101 rows returns 400 atomically", async () => {
85
+ const app = await buildApp();
86
+ await app.inject({
87
+ method: "PUT",
88
+ url: "/api/settings/repository-mappings",
89
+ payload: { mappings: [{ label: "keep", repository: "a/b" }] },
90
+ });
91
+ const lastValid = { mappings: [{ label: "keep", repository: "github.com/a/b" }] };
92
+ for (const payload of [
93
+ { mappings: [{ label: " ", repository: "a/b" }] },
94
+ { mappings: [{ label: "ok", repository: "not a repo!!" }] },
95
+ {
96
+ mappings: Array.from({ length: 101 }, (_, i) => ({ label: `l${i}`, repository: "a/b" })),
97
+ },
98
+ { mappings: "nope" },
99
+ ]) {
100
+ const res = await app.inject({
101
+ method: "PUT",
102
+ url: "/api/settings/repository-mappings",
103
+ payload,
104
+ });
105
+ assert.equal(res.statusCode, 400);
106
+ assert.ok(res.json().error, "readable error");
107
+ }
108
+ const get = await app.inject({ method: "GET", url: "/api/settings/repository-mappings" });
109
+ assert.deepEqual(get.json(), lastValid);
110
+ });
@@ -17,12 +17,14 @@ process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
17
17
  process.env.AGENT_DEALER_CODEX_CAPACITY_REFRESH = "off";
18
18
  const { migrate } = await import("../db/index.js");
19
19
  const { createAgent } = await import("../repository/agents.js");
20
- const { clearAllCapacitySnapshots, listCapacitySnapshots } = await import("../repository/runtime-capacity.js");
21
- const { fixtureMultiWindowAdapter } = await import("../capacity/adapter.js");
20
+ const { clearAllCapacitySnapshots, listCapacitySnapshots, recordCapacitySnapshots } = await import("../repository/runtime-capacity.js");
21
+ const { fixtureMultiWindowAdapter, normalizeAdapterWindow } = await import("../capacity/adapter.js");
22
22
  const { refreshCapacityFromAdapters } = await import("../capacity/service.js");
23
23
  const { resetMuseCapacityRefreshState } = await import("../capacity/muse.js");
24
+ const { refreshCodexCapacityFromAppServer } = await import("../capacity/codex-app-server.js");
24
25
  const { registerRoutes } = await import("./index.js");
25
26
  const { RuntimeCapacityResponse } = await import("@agent-dealer/shared");
27
+ const FAKE_CODEX = new URL("../capacity/fixtures/fake-codex-app-server.mjs", import.meta.url).pathname;
26
28
  migrate();
27
29
  async function buildApp() {
28
30
  const app = Fastify();
@@ -104,6 +106,74 @@ test("GET triggers the Muse refresh when muse_code is configured", async () => {
104
106
  resetMuseCapacityRefreshState();
105
107
  }
106
108
  });
109
+ test("GET serves one logical Codex pair after a dedup refresh of legacy duplicates", async () => {
110
+ // NOT-263: databases written by older versions hold both the aggregate
111
+ // aliases (`codex_rate_limit_primary/secondary`) and the detailed rows for
112
+ // the same logical pair. One refresh against a mirroring payload must
113
+ // delete the aliases, and GET must serve each logical window once.
114
+ clearAllCapacitySnapshots();
115
+ createAgent({
116
+ name: "route-codex-dedup",
117
+ runtime: "codex_local",
118
+ deckId: "55555555-5555-4555-8555-555555555555",
119
+ });
120
+ const seedNow = Date.now();
121
+ const seedObserved = new Date(seedNow).toISOString();
122
+ const seed = (windowKey, providerBucket, durationMinutes) => normalizeAdapterWindow("codex_local", {
123
+ windowKey,
124
+ providerBucket,
125
+ durationMinutes,
126
+ providerLabel: providerBucket,
127
+ usedValue: 9,
128
+ usedUnit: "percent",
129
+ usedPercent: 9,
130
+ resetAt: new Date(seedNow + 2 * 3600_000).toISOString(),
131
+ observedAt: seedObserved,
132
+ source: "supported_protocol",
133
+ }, seedNow);
134
+ recordCapacitySnapshots("codex_local", [
135
+ seed("codex_rate_limit_primary", "primary", 300),
136
+ seed("codex_rate_limit_secondary", "secondary", 10080),
137
+ seed("codex_limit_main_primary", "main/primary", 300),
138
+ seed("codex_limit_main_secondary", "main/secondary", 10080),
139
+ ]);
140
+ assert.equal(listCapacitySnapshots("codex_local").length, 4);
141
+ const now = Date.now();
142
+ await refreshCodexCapacityFromAppServer({
143
+ command: process.execPath,
144
+ args: [FAKE_CODEX],
145
+ env: { FAKE_CODEX_MODE: "mirror", FAKE_CODEX_NOW_MS: String(now) },
146
+ timeoutMs: 10_000,
147
+ nowMs: now,
148
+ });
149
+ const app = await buildApp();
150
+ try {
151
+ const res = await app.inject({ method: "GET", url: "/api/runtime-capacity" });
152
+ assert.equal(res.statusCode, 200);
153
+ const body = RuntimeCapacityResponse.parse(res.json());
154
+ const codex = body.runtimes.find((r) => r.runtime === "codex_local");
155
+ assert.ok(codex, "codex_local entry served");
156
+ assert.equal(codex.unavailableReason, null);
157
+ const keys = codex.windows.map((w) => w.windowKey);
158
+ assert.ok(!keys.some((k) => k.startsWith("codex_rate_limit_")), `no aggregate aliases, got ${keys}`);
159
+ // Mirrored `main` pair renders once (5H + weekly); the genuinely
160
+ // distinct `extra` bucket renders as its own pair.
161
+ assert.deepEqual([...keys].sort(), [
162
+ "codex_limit_extra_primary",
163
+ "codex_limit_extra_secondary",
164
+ "codex_limit_main_primary",
165
+ "codex_limit_main_secondary",
166
+ ]);
167
+ const mainPrimary = codex.windows.find((w) => w.windowKey === "codex_limit_main_primary");
168
+ assert.equal(mainPrimary.displayLabel, "5H");
169
+ assert.equal(mainPrimary.remainingPercent, 30);
170
+ const raw = JSON.stringify(res.json());
171
+ assert.ok(!raw.includes("evidence"), "no evidence pointers leak to the browser");
172
+ }
173
+ finally {
174
+ await app.close();
175
+ }
176
+ });
107
177
  test("GET /api/runtime-capacity serves a configured Codex account without spawning", async () => {
108
178
  clearAllCapacitySnapshots();
109
179
  createAgent({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.2.1",
25
+ "@agent-dealer/shared": "1.2.2",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",