agent-dealer 1.2.0 → 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 (45) 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/coordinator/developer-effect.js +12 -7
  15. package/bundle/server/dist/coordinator/developer-effect.test.js +142 -5
  16. package/bundle/server/dist/db/index.js +30 -0
  17. package/bundle/server/dist/db/schema.sql +27 -0
  18. package/bundle/server/dist/index.js +2 -0
  19. package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
  20. package/bundle/server/dist/repository/repository-mappings.js +46 -0
  21. package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
  22. package/bundle/server/dist/repository/runtime-capacity.js +8 -3
  23. package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
  24. package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
  25. package/bundle/server/dist/routes/index.js +32 -0
  26. package/bundle/server/dist/routes/intake-linear.test.js +55 -1
  27. package/bundle/server/dist/routes/repository-mappings.js +22 -0
  28. package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
  29. package/bundle/server/dist/routes/runtime-capacity.test.js +72 -2
  30. package/bundle/server/package.json +2 -2
  31. package/bundle/server/static-ui/assets/index-0GDYfbaM.js +60 -0
  32. package/bundle/server/static-ui/assets/index-DUYzboWh.css +1 -0
  33. package/bundle/server/static-ui/index.html +2 -2
  34. package/bundle/shared/dist/index.d.ts +6 -6
  35. package/bundle/shared/dist/linear-intake.d.ts +71 -0
  36. package/bundle/shared/dist/linear-intake.js +90 -0
  37. package/bundle/shared/dist/linear-intake.test.js +80 -1
  38. package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
  39. package/bundle/shared/dist/runtime-capacity.js +60 -0
  40. package/bundle/shared/dist/runtime-capacity.test.js +1 -0
  41. package/bundle/shared/package.json +1 -1
  42. package/dist/doctor.js +6 -0
  43. package/package.json +1 -1
  44. package/bundle/server/static-ui/assets/index-J1u7JDns.css +0 -1
  45. package/bundle/server/static-ui/assets/index-obJbiSaU.js +0 -60
@@ -389,6 +389,11 @@ export function migrate() {
389
389
  ON runtime_capacity_snapshots(runtime);
390
390
  `);
391
391
  }
392
+ // NOT-264 repair: critical_role on a table that predates it.
393
+ const capacityCols = db.prepare("PRAGMA table_info(runtime_capacity_snapshots)").all();
394
+ if (!capacityCols.some((c) => c.name === "critical_role")) {
395
+ db.exec("ALTER TABLE runtime_capacity_snapshots ADD COLUMN critical_role TEXT");
396
+ }
392
397
  // NOT-249: normalized Cursor-team billing snapshot for databases created
393
398
  // before schema.sql declared the table (same upgrade-path pattern as
394
399
  // runtime_capacity_snapshots above).
@@ -433,6 +438,31 @@ export function migrate() {
433
438
  db.exec("ALTER TABLE cursor_team_billing_snapshots ADD COLUMN member_limit_override_count REAL");
434
439
  }
435
440
  }
441
+ // NOT-250: normalized Cursor-individual billing snapshot for databases
442
+ // created before schema.sql declared the table (same upgrade-path pattern
443
+ // as cursor_team_billing_snapshots above).
444
+ const cursorIndividualBilling = db
445
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cursor_individual_billing_snapshots'")
446
+ .get();
447
+ if (!cursorIndividualBilling) {
448
+ db.exec(`
449
+ CREATE TABLE cursor_individual_billing_snapshots (
450
+ id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
451
+ cycle_label TEXT,
452
+ cycle_start TEXT,
453
+ cycle_end TEXT,
454
+ usage_value REAL,
455
+ usage_unit TEXT,
456
+ remaining_percent REAL,
457
+ source TEXT NOT NULL,
458
+ unavailable_reason TEXT,
459
+ observed_at TEXT NOT NULL,
460
+ fresh_until TEXT,
461
+ expires_at TEXT,
462
+ evidence_ref TEXT
463
+ );
464
+ `);
465
+ }
436
466
  // NOT-103: sequential issue admission queue.
437
467
  const queueEntries = db
438
468
  .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'queue_entries'")
@@ -400,6 +400,12 @@ CREATE TABLE IF NOT EXISTS runtime_capacity_snapshots (
400
400
  source TEXT NOT NULL,
401
401
  unavailable_reason TEXT,
402
402
  evidence_ref TEXT,
403
+ -- NOT-264 repair: which account-wide critical window this is ('five_hour'
404
+ -- / 'weekly'), set once by the adapter that knows the provider's own
405
+ -- identity (e.g. Codex's rateLimits aggregate, or the detailed limit pair
406
+ -- it collapsed into) — never re-derived client-side from a window key or
407
+ -- label, which cannot disambiguate multiple same-duration buckets.
408
+ critical_role TEXT,
403
409
  PRIMARY KEY (runtime, window_key)
404
410
  );
405
411
 
@@ -431,6 +437,27 @@ CREATE TABLE IF NOT EXISTS cursor_team_billing_snapshots (
431
437
  evidence_ref TEXT
432
438
  );
433
439
 
440
+ -- NOT-250: normalized Cursor-individual billing snapshot (single row, id = 1).
441
+ -- Experimental dashboard billing-cycle state, not per-runtime quota windows:
442
+ -- independent of both runtime_capacity_snapshots and runtime_availability
443
+ -- (NOT-111 health). Carries normalized values plus a static evidence pointer
444
+ -- only — never credential material.
445
+ CREATE TABLE IF NOT EXISTS cursor_individual_billing_snapshots (
446
+ id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
447
+ cycle_label TEXT,
448
+ cycle_start TEXT,
449
+ cycle_end TEXT,
450
+ usage_value REAL,
451
+ usage_unit TEXT,
452
+ remaining_percent REAL,
453
+ source TEXT NOT NULL,
454
+ unavailable_reason TEXT,
455
+ observed_at TEXT NOT NULL,
456
+ fresh_until TEXT,
457
+ expires_at TEXT,
458
+ evidence_ref TEXT
459
+ );
460
+
434
461
  -- NOT-111: account-level runtime usage caps (keyed per runtime, not per agent profile).
435
462
  CREATE TABLE IF NOT EXISTS runtime_availability (
436
463
  runtime TEXT PRIMARY KEY,
@@ -10,6 +10,7 @@ import { registerQueueRoutes } from "./routes/queue.js";
10
10
  import { registerHumanActionRoutes } from "./routes/human-actions.js";
11
11
  import { registerExecutionAnalysisRoutes } from "./routes/execution-analysis.js";
12
12
  import { registerExecutionReportRoutes } from "./routes/execution-report.js";
13
+ import { registerRepositoryMappingsRoutes } from "./routes/repository-mappings.js";
13
14
  import { recoverCoordinator } from "./coordinator/recovery.js";
14
15
  import { startCoordinatorLoop } from "./coordinator/worker-loop.js";
15
16
  import { startLinearUsageSummary } from "./adapters/linear-graphql.js";
@@ -64,6 +65,7 @@ async function main() {
64
65
  await registerHumanActionRoutes(app);
65
66
  await registerExecutionAnalysisRoutes(app);
66
67
  await registerExecutionReportRoutes(app);
68
+ await registerRepositoryMappingsRoutes(app);
67
69
  const uiDist = await registerStaticUi(app);
68
70
  // Without this, getEffectHandler() falls back to the always-session_failed placeholders
69
71
  // (effect-registry.ts) and every started issue would burn its infra retries and
@@ -0,0 +1,84 @@
1
+ // packages/server/src/repository/cursor-individual-billing.ts
2
+ //
3
+ // NOT-250: durable normalized Cursor-individual billing snapshot. Single-row
4
+ // table (`id = 1`): the experimental dashboard adapter reports one
5
+ // billing-cycle state, not per-window readings. Independent of
6
+ // `runtime_availability` (NOT-111 connection health) and written alongside
7
+ // (never instead of) `runtime_capacity_snapshots` — nothing here reads or
8
+ // writes either table.
9
+ //
10
+ // Credential hygiene: this table carries normalized billing values and a
11
+ // static evidence pointer only. The raw local-login token must never be
12
+ // written here — the adapter never passes it to this module, and tests scan
13
+ // stored rows for fixture secrets.
14
+ import { getDb } from "../db/index.js";
15
+ function rowToSnapshot(row) {
16
+ return {
17
+ cycleLabel: row.cycle_label,
18
+ cycleStart: row.cycle_start,
19
+ cycleEnd: row.cycle_end,
20
+ usageValue: row.usage_value,
21
+ usageUnit: row.usage_unit,
22
+ remainingPercent: row.remaining_percent,
23
+ source: row.source,
24
+ unavailableReason: row.unavailable_reason,
25
+ observedAt: row.observed_at,
26
+ freshUntil: row.fresh_until,
27
+ expiresAt: row.expires_at,
28
+ evidenceRef: row.evidence_ref,
29
+ };
30
+ }
31
+ /** Upsert the latest normalized individual billing observation (single row). */
32
+ export function writeCursorIndividualBillingRow(input) {
33
+ getDb()
34
+ .prepare(`
35
+ INSERT INTO cursor_individual_billing_snapshots (
36
+ id, cycle_label, cycle_start, cycle_end, usage_value, usage_unit,
37
+ remaining_percent, source, unavailable_reason,
38
+ observed_at, fresh_until, expires_at, evidence_ref
39
+ )
40
+ VALUES (
41
+ 1, @cycle_label, @cycle_start, @cycle_end, @usage_value, @usage_unit,
42
+ @remaining_percent, @source, @unavailable_reason,
43
+ @observed_at, @fresh_until, @expires_at, @evidence_ref
44
+ )
45
+ ON CONFLICT(id) DO UPDATE SET
46
+ cycle_label = excluded.cycle_label,
47
+ cycle_end = excluded.cycle_end,
48
+ cycle_start = excluded.cycle_start,
49
+ usage_value = excluded.usage_value,
50
+ usage_unit = excluded.usage_unit,
51
+ remaining_percent = excluded.remaining_percent,
52
+ source = excluded.source,
53
+ unavailable_reason = excluded.unavailable_reason,
54
+ observed_at = excluded.observed_at,
55
+ fresh_until = excluded.fresh_until,
56
+ expires_at = excluded.expires_at,
57
+ evidence_ref = excluded.evidence_ref
58
+ `)
59
+ .run({
60
+ cycle_label: input.cycleLabel,
61
+ cycle_start: input.cycleStart,
62
+ cycle_end: input.cycleEnd,
63
+ usage_value: input.usageValue,
64
+ usage_unit: input.usageUnit,
65
+ remaining_percent: input.remainingPercent,
66
+ source: input.source,
67
+ unavailable_reason: input.unavailableReason,
68
+ observed_at: input.observedAt,
69
+ fresh_until: input.freshUntil,
70
+ expires_at: input.expiresAt,
71
+ evidence_ref: input.evidenceRef,
72
+ });
73
+ }
74
+ /** The latest normalized individual billing observation, if any. */
75
+ export function readCursorIndividualBillingRow() {
76
+ const row = getDb()
77
+ .prepare("SELECT * FROM cursor_individual_billing_snapshots WHERE id = 1")
78
+ .get();
79
+ return row ? rowToSnapshot(row) : null;
80
+ }
81
+ /** Test helper — clear the individual billing snapshot (leaves all other tables alone). */
82
+ export function clearCursorIndividualBilling() {
83
+ getDb().exec("DELETE FROM cursor_individual_billing_snapshots");
84
+ }
@@ -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
+ }