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.
- 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/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-ChnI0F02.css +0 -1
- 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
|
-
? {
|
|
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.
|
|
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.
|
|
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",
|