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.
- package/bundle/server/dist/adapters/linear-inbox.js +9 -5
- package/bundle/server/dist/capacity/adapter.js +2 -0
- package/bundle/server/dist/capacity/claude-events.js +18 -0
- package/bundle/server/dist/capacity/claude-events.test.js +7 -0
- package/bundle/server/dist/capacity/codex-app-server.js +100 -10
- package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
- package/bundle/server/dist/capacity/cursor-individual-credentials.js +238 -0
- package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +185 -0
- package/bundle/server/dist/capacity/cursor-individual.js +970 -0
- package/bundle/server/dist/capacity/cursor-individual.test.js +815 -0
- package/bundle/server/dist/capacity/muse.js +94 -43
- package/bundle/server/dist/capacity/muse.test.js +95 -13
- package/bundle/server/dist/capacity/service.js +1 -0
- package/bundle/server/dist/coordinator/developer-effect.js +12 -7
- package/bundle/server/dist/coordinator/developer-effect.test.js +142 -5
- package/bundle/server/dist/db/index.js +30 -0
- package/bundle/server/dist/db/schema.sql +27 -0
- package/bundle/server/dist/index.js +2 -0
- package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
- package/bundle/server/dist/repository/repository-mappings.js +46 -0
- package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
- package/bundle/server/dist/repository/runtime-capacity.js +8 -3
- package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
- package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
- package/bundle/server/dist/routes/index.js +32 -0
- package/bundle/server/dist/routes/intake-linear.test.js +55 -1
- package/bundle/server/dist/routes/repository-mappings.js +22 -0
- package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
- package/bundle/server/dist/routes/runtime-capacity.test.js +72 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-0GDYfbaM.js +60 -0
- package/bundle/server/static-ui/assets/index-DUYzboWh.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +6 -6
- package/bundle/shared/dist/linear-intake.d.ts +71 -0
- package/bundle/shared/dist/linear-intake.js +90 -0
- package/bundle/shared/dist/linear-intake.test.js +80 -1
- package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
- package/bundle/shared/dist/runtime-capacity.js +60 -0
- package/bundle/shared/dist/runtime-capacity.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.js +6 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-J1u7JDns.css +0 -1
- 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
|
-
? {
|
|
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
|
+
}
|