agent-dealer 1.2.11 → 1.2.13
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 +72 -16
- package/bundle/server/dist/adapters/linear-inbox.test.js +183 -1
- package/bundle/server/dist/coordinator/commands.js +204 -5
- package/bundle/server/dist/coordinator/commands.test.js +288 -3
- package/bundle/server/dist/repository/intake-settings.js +50 -0
- package/bundle/server/dist/repository/intake-settings.test.js +72 -0
- package/bundle/server/dist/routes/index.js +6 -3
- package/bundle/server/dist/routes/intake-linear-config.js +31 -0
- package/bundle/server/dist/routes/intake-linear.test.js +95 -5
- package/bundle/server/dist/routes/issues.js +54 -7
- package/bundle/server/dist/routes/issues.test.js +326 -7
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-BzLjMBI9.js +64 -0
- package/bundle/server/static-ui/assets/index-COYJxD1y.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +303 -0
- package/bundle/shared/dist/index.js +47 -0
- package/bundle/shared/dist/linear-intake.d.ts +14 -0
- package/bundle/shared/dist/linear-intake.js +23 -0
- package/bundle/shared/dist/linear-intake.test.js +41 -2
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +2 -0
- package/dist/issue.d.ts +8 -0
- package/dist/issue.js +35 -0
- package/dist/issue.test.js +92 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Cf4dipuK.js +0 -64
- package/bundle/server/static-ui/assets/index-K_YcYkQU.css +0 -1
|
@@ -7,14 +7,17 @@ import path from "node:path";
|
|
|
7
7
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cmds-"));
|
|
8
8
|
const { migrate, getDb } = await import("../db/index.js");
|
|
9
9
|
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { createAgent } = await import("../repository/agents.js");
|
|
11
|
+
const { parseProfileSnapshot } = await import("@agent-dealer/shared");
|
|
12
|
+
const { randomUUID } = await import("node:crypto");
|
|
10
13
|
const { createIssue, getIssue, updateIssue } = await import("../repository/issues.js");
|
|
11
14
|
const { listWorkflowEventsForIssue, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
|
|
12
15
|
const { createHumanAction, getHumanAction, listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
13
16
|
const { setResumeLiveHeadReaderForTests } = await import("./resume-live-head.js");
|
|
14
17
|
const { listFindingsForIssue, reconcileFinding } = await import("../repository/findings.js");
|
|
15
18
|
const { cancelWorkItem, claimWorkItem, listWorkItemsForIssue, getWorkItem, enqueueWorkItem } = await import("../repository/work-items.js");
|
|
16
|
-
const { createWorkerSession, startSession, listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
17
|
-
const { startWorkflow, applyCompletion, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, getTaskSnapshot, canEditParkedIssue, TASK_SNAPSHOT_ARTIFACT_KIND, checkIssueReadiness, abortIssue } = await import("./commands.js");
|
|
19
|
+
const { createWorkerSession, completeSession, startSession, listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
20
|
+
const { startWorkflow, applyCompletion, applyParkedEdit, capWaitForIssue, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, getTaskSnapshot, canEditParkedIssue, parkCapWait, TASK_SNAPSHOT_ARTIFACT_KIND, checkIssueReadiness, abortIssue } = await import("./commands.js");
|
|
18
21
|
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
19
22
|
const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
|
|
20
23
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
@@ -330,9 +333,16 @@ async function parkAtAttemptsExhausted() {
|
|
|
330
333
|
return { issueId, actionId: action.id };
|
|
331
334
|
}
|
|
332
335
|
const snapshotArtifacts = (issueId) => listArtifactsForIssue(issueId).filter((a) => a.kind === TASK_SNAPSHOT_ARTIFACT_KIND);
|
|
333
|
-
test("NOT-185: canEditParkedIssue is true only at an open attempts_exhausted with nothing pending or leased", async () => {
|
|
336
|
+
test("NOT-185/NOT-358: canEditParkedIssue is true only at an open attempts_exhausted or policy_escalation with nothing pending or leased", async () => {
|
|
334
337
|
const { issueId } = await parkAtAttemptsExhausted();
|
|
335
338
|
assert.equal(canEditParkedIssue(getIssue(issueId)), true);
|
|
339
|
+
// NOT-358: a policy_escalation park (e.g. after a developer budget failure) is
|
|
340
|
+
// editable the same way, so the owner can swap agents and resume in place.
|
|
341
|
+
const cappedId = newIssue({ maxInfraAttempts: 0 });
|
|
342
|
+
startWorkflow(cappedId);
|
|
343
|
+
await complete(cappedId, { kind: "session_failed" });
|
|
344
|
+
assert.equal(listHumanActionsForIssue(cappedId).find((a) => a.status === "open").actionType, "policy_escalation");
|
|
345
|
+
assert.equal(canEditParkedIssue(getIssue(cappedId)), true);
|
|
336
346
|
const instance = getActiveWorkflowInstance(issueId);
|
|
337
347
|
const item = enqueueWorkItem({
|
|
338
348
|
issueId,
|
|
@@ -940,3 +950,278 @@ test("NOT-184: resolving the escalation with resume continues without re-trigger
|
|
|
940
950
|
assert.equal(getIssue(issueId).status, "needs_human");
|
|
941
951
|
assert.match(openNonConvergence(issueId).reason, /rounds 4, 5, 6/);
|
|
942
952
|
});
|
|
953
|
+
/** Swap-target profiles with runtimes distinct from the builtins the issue starts on. */
|
|
954
|
+
function n358Agents() {
|
|
955
|
+
const deckId = "00000000-0000-4000-a000-000000000358";
|
|
956
|
+
const dev = createAgent({ name: `n358-dev-${Math.random()}`, runtime: "codex_local", deckId });
|
|
957
|
+
const rev = createAgent({ name: `n358-rev-${Math.random()}`, runtime: "cursor_local", deckId });
|
|
958
|
+
return { dev, rev };
|
|
959
|
+
}
|
|
960
|
+
function reassignedEvents(issueId) {
|
|
961
|
+
return listWorkflowEventsForIssue(issueId).filter((e) => e.type === "issue.reassigned");
|
|
962
|
+
}
|
|
963
|
+
/** The frozen profile snapshot a resumed round carries — what the next session spawns from. */
|
|
964
|
+
function pendingSnapshot(issueId, kind) {
|
|
965
|
+
const item = listWorkItemsForIssue(issueId).find((i) => i.status === "pending" && i.kind === kind);
|
|
966
|
+
assert.ok(item, `expected a pending ${kind} work item`);
|
|
967
|
+
const payload = JSON.parse(item.payloadJson);
|
|
968
|
+
const snapshot = parseProfileSnapshot(payload.profileSnapshot);
|
|
969
|
+
assert.ok(snapshot, "the resumed round carries a frozen profile snapshot");
|
|
970
|
+
return snapshot;
|
|
971
|
+
}
|
|
972
|
+
test("NOT-358: applyParkedEdit swaps each role alone and both, keeping worktree, branch and rounds, one reassigned event per swap", async () => {
|
|
973
|
+
const { dev, rev } = n358Agents();
|
|
974
|
+
const { issueId, actionId } = await parkAtAttemptsExhausted();
|
|
975
|
+
const before = getIssue(issueId);
|
|
976
|
+
assert.equal(before.branch, "issue-1");
|
|
977
|
+
const devSwap = applyParkedEdit(issueId, { developerAgentId: dev.id });
|
|
978
|
+
assert.equal(devSwap.ok, true);
|
|
979
|
+
if (devSwap.ok !== true)
|
|
980
|
+
return;
|
|
981
|
+
assert.equal(devSwap.issue.developerAgentId, dev.id);
|
|
982
|
+
assert.equal(devSwap.issue.reviewerAgentId, before.reviewerAgentId);
|
|
983
|
+
assert.equal(devSwap.issue.branch, before.branch);
|
|
984
|
+
assert.deepEqual([devSwap.issue.currentRound, devSwap.issue.maxReviewRounds], [before.currentRound, before.maxReviewRounds]);
|
|
985
|
+
assert.equal(reassignedEvents(issueId).length, 1);
|
|
986
|
+
assert.deepEqual(JSON.parse(reassignedEvents(issueId)[0].payloadJson), {
|
|
987
|
+
fromRepo: before.repo,
|
|
988
|
+
toRepo: before.repo,
|
|
989
|
+
fromDeveloperAgentId: before.developerAgentId,
|
|
990
|
+
toDeveloperAgentId: dev.id,
|
|
991
|
+
fromReviewerAgentId: before.reviewerAgentId,
|
|
992
|
+
toReviewerAgentId: before.reviewerAgentId,
|
|
993
|
+
});
|
|
994
|
+
const revSwap = applyParkedEdit(issueId, { reviewerAgentId: rev.id });
|
|
995
|
+
assert.equal(revSwap.ok, true);
|
|
996
|
+
if (revSwap.ok !== true)
|
|
997
|
+
return;
|
|
998
|
+
assert.equal(revSwap.issue.reviewerAgentId, rev.id);
|
|
999
|
+
assert.equal(revSwap.issue.branch, before.branch);
|
|
1000
|
+
assert.deepEqual([revSwap.issue.currentRound, revSwap.issue.maxReviewRounds], [before.currentRound, before.maxReviewRounds]);
|
|
1001
|
+
assert.equal(reassignedEvents(issueId).length, 2);
|
|
1002
|
+
// Both roles in one call: a single event carrying both changes.
|
|
1003
|
+
const both = await parkAtAttemptsExhausted();
|
|
1004
|
+
const bothSwap = applyParkedEdit(both.issueId, { developerAgentId: dev.id, reviewerAgentId: rev.id });
|
|
1005
|
+
assert.equal(bothSwap.ok, true);
|
|
1006
|
+
if (bothSwap.ok !== true)
|
|
1007
|
+
return;
|
|
1008
|
+
assert.equal(bothSwap.issue.developerAgentId, dev.id);
|
|
1009
|
+
assert.equal(bothSwap.issue.reviewerAgentId, rev.id);
|
|
1010
|
+
assert.equal(reassignedEvents(both.issueId).length, 1);
|
|
1011
|
+
// Resume freezes the NEW developer profile into the next round's payload — the
|
|
1012
|
+
// worker loop builds the spawned session's runtime from exactly this snapshot.
|
|
1013
|
+
const resolved = resolveHumanActionAndAdvance(actionId, "yusuke", "retry");
|
|
1014
|
+
assert.equal(resolved.ok, true);
|
|
1015
|
+
const snapshot = pendingSnapshot(issueId, "developer");
|
|
1016
|
+
assert.equal(snapshot.agentId, dev.id);
|
|
1017
|
+
assert.equal(snapshot.runtime, "codex_local");
|
|
1018
|
+
assert.equal(snapshot.role, "developer");
|
|
1019
|
+
});
|
|
1020
|
+
test("NOT-358: applyParkedEdit rejects unknown agents (400), locked fields, pending items and running sessions (409)", async () => {
|
|
1021
|
+
const { dev } = n358Agents();
|
|
1022
|
+
const { issueId } = await parkAtAttemptsExhausted();
|
|
1023
|
+
const before = getIssue(issueId);
|
|
1024
|
+
const reassignedBefore = reassignedEvents(issueId).length;
|
|
1025
|
+
const unknown = applyParkedEdit(issueId, { developerAgentId: randomUUID() });
|
|
1026
|
+
assert.equal(unknown.ok, false);
|
|
1027
|
+
if (unknown.ok !== false)
|
|
1028
|
+
return;
|
|
1029
|
+
assert.equal(unknown.code, 400);
|
|
1030
|
+
assert.match(unknown.error, /Unknown agent profile/);
|
|
1031
|
+
const unknownRev = applyParkedEdit(issueId, { reviewerAgentId: randomUUID() });
|
|
1032
|
+
assert.equal(unknownRev.ok, false);
|
|
1033
|
+
if (unknownRev.ok !== false)
|
|
1034
|
+
return;
|
|
1035
|
+
assert.equal(unknownRev.code, 400);
|
|
1036
|
+
assert.equal(getIssue(issueId).developerAgentId, before.developerAgentId);
|
|
1037
|
+
assert.equal(getIssue(issueId).reviewerAgentId, before.reviewerAgentId);
|
|
1038
|
+
assert.equal(reassignedEvents(issueId).length, reassignedBefore);
|
|
1039
|
+
for (const patch of [{ repo: "acme/other" }, { maxReviewRounds: 5 }, { autoMerge: true }]) {
|
|
1040
|
+
const blocked = applyParkedEdit(issueId, patch);
|
|
1041
|
+
assert.equal(blocked.ok, false, JSON.stringify(patch));
|
|
1042
|
+
if (blocked.ok !== false)
|
|
1043
|
+
return;
|
|
1044
|
+
assert.equal(blocked.code, 409);
|
|
1045
|
+
}
|
|
1046
|
+
assert.equal(getIssue(issueId).maxReviewRounds, before.maxReviewRounds);
|
|
1047
|
+
// A pending work item blocks the swap; cancelling it re-opens the surface.
|
|
1048
|
+
const instance = getActiveWorkflowInstance(issueId);
|
|
1049
|
+
const item = enqueueWorkItem({
|
|
1050
|
+
issueId,
|
|
1051
|
+
workflowInstanceId: instance.id,
|
|
1052
|
+
kind: "developer",
|
|
1053
|
+
round: 1,
|
|
1054
|
+
payload: {},
|
|
1055
|
+
idempotencyKey: `${instance.id}:test:n358-pending`,
|
|
1056
|
+
});
|
|
1057
|
+
const queued = applyParkedEdit(issueId, { developerAgentId: dev.id });
|
|
1058
|
+
assert.equal(queued.ok, false);
|
|
1059
|
+
if (queued.ok !== false)
|
|
1060
|
+
return;
|
|
1061
|
+
assert.equal(queued.code, 409);
|
|
1062
|
+
cancelWorkItem(item.id);
|
|
1063
|
+
const afterCancel = applyParkedEdit(issueId, { developerAgentId: dev.id });
|
|
1064
|
+
assert.equal(afterCancel.ok, true);
|
|
1065
|
+
// An actively running session blocks the swap even with nothing pending/leased.
|
|
1066
|
+
const session = createWorkerSession({
|
|
1067
|
+
issueId,
|
|
1068
|
+
role: "developer",
|
|
1069
|
+
round: 1,
|
|
1070
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1071
|
+
runtime: "claude_code",
|
|
1072
|
+
});
|
|
1073
|
+
startSession(session.id);
|
|
1074
|
+
const running = applyParkedEdit(issueId, { reviewerAgentId: BUILTIN_AGENT_CLAUDE_ID });
|
|
1075
|
+
assert.equal(running.ok, false);
|
|
1076
|
+
if (running.ok !== false)
|
|
1077
|
+
return;
|
|
1078
|
+
assert.equal(running.code, 409);
|
|
1079
|
+
assert.match(running.error, /running session/);
|
|
1080
|
+
completeSession(session.id, { status: "done" });
|
|
1081
|
+
const afterSession = applyParkedEdit(issueId, { title: "Renamed at park" });
|
|
1082
|
+
assert.equal(afterSession.ok, true);
|
|
1083
|
+
});
|
|
1084
|
+
test("NOT-358: parkCapWait parks a usage-cap wait for a human; swap + resume continue in the same worktree", async () => {
|
|
1085
|
+
const { dev } = n358Agents();
|
|
1086
|
+
const issueId = newIssue();
|
|
1087
|
+
startWorkflow(issueId);
|
|
1088
|
+
const until = new Date(Date.now() + 300_000).toISOString();
|
|
1089
|
+
const capReason = "claude_code usage capped — five_hour limit rejected";
|
|
1090
|
+
await complete(issueId, { kind: "usage_capped", until, reason: capReason });
|
|
1091
|
+
const wait = capWaitForIssue(issueId);
|
|
1092
|
+
assert.deepEqual(wait, { kind: "usage_capped", until, reason: capReason });
|
|
1093
|
+
// The attempt's session and branch predate the park — both must survive it.
|
|
1094
|
+
const session = createWorkerSession({
|
|
1095
|
+
issueId,
|
|
1096
|
+
role: "developer",
|
|
1097
|
+
round: 1,
|
|
1098
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1099
|
+
runtime: "claude_code",
|
|
1100
|
+
});
|
|
1101
|
+
startSession(session.id);
|
|
1102
|
+
completeSession(session.id, { status: "done", worktreePath: "/tmp/n358-worktree" });
|
|
1103
|
+
const { transitionIssue } = await import("../repository/issues.js");
|
|
1104
|
+
transitionIssue(issueId, "developing", { branch: "dealer/n358-park" });
|
|
1105
|
+
const parked = parkCapWait(issueId, "op");
|
|
1106
|
+
assert.equal(parked.ok, true);
|
|
1107
|
+
if (parked.ok !== true)
|
|
1108
|
+
return;
|
|
1109
|
+
assert.equal(parked.issueStatus, "needs_human");
|
|
1110
|
+
const issue = getIssue(issueId);
|
|
1111
|
+
assert.equal(issue.currentOwner, "human");
|
|
1112
|
+
assert.match(issue.currentIntent ?? "", /five_hour/);
|
|
1113
|
+
assert.match(issue.currentIntent ?? "", new RegExp(until.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1114
|
+
assert.equal(issue.branch, "dealer/n358-park");
|
|
1115
|
+
assert.deepEqual([issue.currentRound, issue.maxReviewRounds], [1, 3]);
|
|
1116
|
+
const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
1117
|
+
assert.equal(open.length, 1);
|
|
1118
|
+
assert.equal(open[0].actionType, "policy_escalation");
|
|
1119
|
+
assert.match(open[0].reason, /five_hour/);
|
|
1120
|
+
assert.match(open[0].reason, /wait until/);
|
|
1121
|
+
assert.equal(capWaitForIssue(issueId), null, "the wait is cancelled, not deferred again");
|
|
1122
|
+
assert.deepEqual(listWorkItemsForIssue(issueId).map((i) => i.status), ["cancelled"], "the deferred wait is cancelled");
|
|
1123
|
+
const keptSession = listWorkerSessionsForIssue(issueId).find((s) => s.id === session.id);
|
|
1124
|
+
assert.equal(keptSession.worktreePath, "/tmp/n358-worktree");
|
|
1125
|
+
// Swap while parked, then resume — the next developer round carries the new profile.
|
|
1126
|
+
const swapped = applyParkedEdit(issueId, { developerAgentId: dev.id });
|
|
1127
|
+
assert.equal(swapped.ok, true);
|
|
1128
|
+
const resolved = resolveHumanActionAndAdvance(open[0].id, "op", "resume");
|
|
1129
|
+
assert.equal(resolved.ok, true);
|
|
1130
|
+
if (resolved.ok !== true)
|
|
1131
|
+
return;
|
|
1132
|
+
assert.equal(resolved.issueStatus, "developing");
|
|
1133
|
+
assert.equal(getIssue(issueId).branch, "dealer/n358-park");
|
|
1134
|
+
const snapshot = pendingSnapshot(issueId, "developer");
|
|
1135
|
+
assert.equal(snapshot.agentId, dev.id);
|
|
1136
|
+
assert.equal(snapshot.runtime, "codex_local");
|
|
1137
|
+
});
|
|
1138
|
+
test("NOT-358: parkCapWait parks a deck-outage wait, and a reviewer wait resumes as reviewer", async () => {
|
|
1139
|
+
const deckId = newIssue();
|
|
1140
|
+
startWorkflow(deckId);
|
|
1141
|
+
await complete(deckId, { kind: "deck_unavailable", reason: "Agent Deck unreachable — connection refused" });
|
|
1142
|
+
assert.equal(capWaitForIssue(deckId)?.kind, "deck_unavailable");
|
|
1143
|
+
const parked = parkCapWait(deckId, "op");
|
|
1144
|
+
assert.equal(parked.ok, true);
|
|
1145
|
+
const open = listHumanActionsForIssue(deckId).filter((a) => a.status === "open");
|
|
1146
|
+
assert.equal(open.length, 1);
|
|
1147
|
+
assert.match(open[0].reason, /Agent Deck outage/);
|
|
1148
|
+
assert.match(open[0].reason, /connection refused/);
|
|
1149
|
+
const resumed = resolveHumanActionAndAdvance(open[0].id, "op", "resume");
|
|
1150
|
+
assert.equal(resumed.ok, true);
|
|
1151
|
+
if (resumed.ok !== true)
|
|
1152
|
+
return;
|
|
1153
|
+
assert.equal(resumed.issueStatus, "developing");
|
|
1154
|
+
// The resume above enqueued a fresh developer item — cancel it so the reviewer flow
|
|
1155
|
+
// below claims its own item (`claim` takes the oldest pending item across issues).
|
|
1156
|
+
for (const item of listWorkItemsForIssue(deckId)) {
|
|
1157
|
+
if (item.status === "pending")
|
|
1158
|
+
cancelWorkItem(item.id);
|
|
1159
|
+
}
|
|
1160
|
+
// A reviewer-role wait tags the continuation so resume re-queues a reviewer at the
|
|
1161
|
+
// pinned head instead of an unrelated developer round.
|
|
1162
|
+
const revId = newIssue();
|
|
1163
|
+
startWorkflow(revId);
|
|
1164
|
+
await complete(revId, cleanHandoff);
|
|
1165
|
+
await complete(revId, { kind: "deck_unavailable", reason: "Agent Deck unreachable — reset by peer" });
|
|
1166
|
+
assert.equal(capWaitForIssue(revId)?.kind, "deck_unavailable");
|
|
1167
|
+
const revParked = parkCapWait(revId, "op");
|
|
1168
|
+
assert.equal(revParked.ok, true);
|
|
1169
|
+
const revOpen = listHumanActionsForIssue(revId).filter((a) => a.status === "open");
|
|
1170
|
+
assert.equal(revOpen.length, 1);
|
|
1171
|
+
const revResumed = resolveHumanActionAndAdvance(revOpen[0].id, "op", "resume");
|
|
1172
|
+
assert.equal(revResumed.ok, true);
|
|
1173
|
+
if (revResumed.ok !== true)
|
|
1174
|
+
return;
|
|
1175
|
+
assert.equal(revResumed.issueStatus, "reviewing");
|
|
1176
|
+
const pending = listWorkItemsForIssue(revId).filter((i) => i.status === "pending");
|
|
1177
|
+
assert.deepEqual(pending.map((i) => i.kind), ["reviewer"]);
|
|
1178
|
+
const payload = JSON.parse(pending[0].payloadJson);
|
|
1179
|
+
assert.equal(payload.inputSha, "abc123");
|
|
1180
|
+
});
|
|
1181
|
+
test("NOT-358: parkCapWait rejects without a wait, with leased work, or with a running session", async () => {
|
|
1182
|
+
const readyId = newIssue();
|
|
1183
|
+
const noInstance = parkCapWait(readyId, "op");
|
|
1184
|
+
assert.equal(noInstance.ok, false);
|
|
1185
|
+
if (noInstance.ok !== false)
|
|
1186
|
+
return;
|
|
1187
|
+
assert.equal(noInstance.code, 409);
|
|
1188
|
+
const plainId = newIssue();
|
|
1189
|
+
startWorkflow(plainId);
|
|
1190
|
+
const noWait = parkCapWait(plainId, "op");
|
|
1191
|
+
assert.equal(noWait.ok, false);
|
|
1192
|
+
if (noWait.ok !== false)
|
|
1193
|
+
return;
|
|
1194
|
+
assert.equal(noWait.code, 409);
|
|
1195
|
+
assert.match(noWait.error, /usage-cap or deck-outage/);
|
|
1196
|
+
const leased = claim(plainId);
|
|
1197
|
+
const leasedPark = parkCapWait(plainId, "op");
|
|
1198
|
+
assert.equal(leasedPark.ok, false);
|
|
1199
|
+
if (leasedPark.ok !== false)
|
|
1200
|
+
return;
|
|
1201
|
+
assert.equal(leasedPark.code, 409);
|
|
1202
|
+
// The failed park changes nothing: the lease is intact and the issue still develops.
|
|
1203
|
+
assert.equal(getIssue(plainId).status, "developing");
|
|
1204
|
+
assert.equal(getWorkItem(leased.id).status, "leased");
|
|
1205
|
+
const until = new Date(Date.now() + 300_000).toISOString();
|
|
1206
|
+
await applyCompletion(leased.id, leased.leaseToken, {
|
|
1207
|
+
kind: "usage_capped",
|
|
1208
|
+
until,
|
|
1209
|
+
reason: "claude_code usage capped — test",
|
|
1210
|
+
});
|
|
1211
|
+
const running = createWorkerSession({
|
|
1212
|
+
issueId: plainId,
|
|
1213
|
+
role: "developer",
|
|
1214
|
+
round: 1,
|
|
1215
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1216
|
+
runtime: "claude_code",
|
|
1217
|
+
});
|
|
1218
|
+
startSession(running.id);
|
|
1219
|
+
const runningPark = parkCapWait(plainId, "op");
|
|
1220
|
+
assert.equal(runningPark.ok, false);
|
|
1221
|
+
if (runningPark.ok !== false)
|
|
1222
|
+
return;
|
|
1223
|
+
assert.equal(runningPark.code, 409);
|
|
1224
|
+
assert.match(runningPark.error, /running session/);
|
|
1225
|
+
assert.equal(getIssue(plainId).status, "developing");
|
|
1226
|
+
assert.ok(capWaitForIssue(plainId), "the wait survives the rejected park");
|
|
1227
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeLinearIntakePickerPatch } from "@agent-dealer/shared";
|
|
1
2
|
import { getDb } from "../db/index.js";
|
|
2
3
|
/** Open workflow states — exclude terminal Done / Canceled. Shared with linear-inbox seed. */
|
|
3
4
|
export const DEFAULT_LINEAR_STATE_FILTER = ["Backlog", "Todo", "In Progress", "In Review"];
|
|
@@ -14,6 +15,11 @@ function getJson(key, fallback) {
|
|
|
14
15
|
return fallback;
|
|
15
16
|
}
|
|
16
17
|
}
|
|
18
|
+
function setJson(key, value) {
|
|
19
|
+
getDb()
|
|
20
|
+
.prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json")
|
|
21
|
+
.run(key, JSON.stringify(value));
|
|
22
|
+
}
|
|
17
23
|
function parseStateFilterEnv(raw) {
|
|
18
24
|
if (!raw?.trim())
|
|
19
25
|
return null;
|
|
@@ -23,6 +29,19 @@ function parseStateFilterEnv(raw) {
|
|
|
23
29
|
.filter(Boolean);
|
|
24
30
|
return parts.length > 0 ? parts : null;
|
|
25
31
|
}
|
|
32
|
+
function linearEnvOverrides() {
|
|
33
|
+
return {
|
|
34
|
+
stateFilter: parseStateFilterEnv(process.env.LINEAR_STATE_FILTER) !== null,
|
|
35
|
+
teamId: process.env.LINEAR_TEAM_ID !== undefined && process.env.LINEAR_TEAM_ID !== "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function toPickerConfig(config) {
|
|
39
|
+
return {
|
|
40
|
+
stateFilter: config.stateFilter,
|
|
41
|
+
teamId: config.teamId,
|
|
42
|
+
assigneeMe: config.assigneeMe,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
26
45
|
/** Persisted-only view of the Linear intake config (env overrides applied separately). */
|
|
27
46
|
export function getPersistedLinearIntakeConfig() {
|
|
28
47
|
return {
|
|
@@ -47,6 +66,37 @@ function applyEnvOverrides(config) {
|
|
|
47
66
|
export function getLinearIntakeConfig() {
|
|
48
67
|
return applyEnvOverrides(getPersistedLinearIntakeConfig());
|
|
49
68
|
}
|
|
69
|
+
/** NOT-361: effective picker filters + persisted values + which env vars win. */
|
|
70
|
+
export function getLinearIntakeConfigView() {
|
|
71
|
+
const persistedFull = getPersistedLinearIntakeConfig();
|
|
72
|
+
const effective = applyEnvOverrides(persistedFull);
|
|
73
|
+
return {
|
|
74
|
+
...toPickerConfig(effective),
|
|
75
|
+
persisted: toPickerConfig(persistedFull),
|
|
76
|
+
envOverrides: linearEnvOverrides(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* NOT-361: persist picker fields only (Team / Assignee / Status).
|
|
81
|
+
* Never touches syncEnabled or the deleted routing/default-agent rows.
|
|
82
|
+
*/
|
|
83
|
+
export function patchLinearIntakeConfig(patch) {
|
|
84
|
+
const normalized = normalizeLinearIntakePickerPatch(patch);
|
|
85
|
+
const current = getPersistedLinearIntakeConfig();
|
|
86
|
+
const nextPicker = {
|
|
87
|
+
stateFilter: normalized.stateFilter ?? current.stateFilter,
|
|
88
|
+
teamId: normalized.teamId !== undefined ? normalized.teamId : current.teamId,
|
|
89
|
+
assigneeMe: normalized.assigneeMe !== undefined ? normalized.assigneeMe : current.assigneeMe,
|
|
90
|
+
};
|
|
91
|
+
// Re-validate the merged picker shape (empty status list is rejected above).
|
|
92
|
+
if (nextPicker.stateFilter.length === 0) {
|
|
93
|
+
throw new Error("Select at least one workflow status");
|
|
94
|
+
}
|
|
95
|
+
setJson("linear.stateFilter", nextPicker.stateFilter);
|
|
96
|
+
setJson("linear.teamId", nextPicker.teamId);
|
|
97
|
+
setJson("linear.assigneeMe", nextPicker.assigneeMe);
|
|
98
|
+
return getLinearIntakeConfigView();
|
|
99
|
+
}
|
|
50
100
|
function parseEnvAgentDeckUrl() {
|
|
51
101
|
const raw = process.env.AGENT_DECK_API_URL;
|
|
52
102
|
if (!raw)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// NOT-361: Linear picker config persistence, env-override precedence, and
|
|
2
|
+
// narrow patch (Team / Assignee / Status only — no routing fields).
|
|
3
|
+
import { test, before, afterEach } 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
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-intake-settings-"));
|
|
9
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
10
|
+
const { getLinearIntakeConfigView, getPersistedLinearIntakeConfig, patchLinearIntakeConfig, DEFAULT_LINEAR_STATE_FILTER, } = await import("./intake-settings.js");
|
|
11
|
+
before(() => {
|
|
12
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
13
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
14
|
+
migrate();
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
18
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
19
|
+
// Reset picker rows to defaults between cases.
|
|
20
|
+
const db = getDb();
|
|
21
|
+
const upsert = db.prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json");
|
|
22
|
+
upsert.run("linear.stateFilter", JSON.stringify([...DEFAULT_LINEAR_STATE_FILTER]));
|
|
23
|
+
upsert.run("linear.teamId", JSON.stringify(null));
|
|
24
|
+
upsert.run("linear.assigneeMe", JSON.stringify(false));
|
|
25
|
+
});
|
|
26
|
+
test("patchLinearIntakeConfig round-trips Team, Assignee, and Status through SQLite", () => {
|
|
27
|
+
const saved = patchLinearIntakeConfig({
|
|
28
|
+
stateFilter: ["Todo", "In Progress"],
|
|
29
|
+
teamId: "team-abc",
|
|
30
|
+
assigneeMe: true,
|
|
31
|
+
});
|
|
32
|
+
assert.deepEqual(saved.persisted, {
|
|
33
|
+
stateFilter: ["Todo", "In Progress"],
|
|
34
|
+
teamId: "team-abc",
|
|
35
|
+
assigneeMe: true,
|
|
36
|
+
});
|
|
37
|
+
assert.deepEqual(saved.stateFilter, ["Todo", "In Progress"]);
|
|
38
|
+
assert.equal(saved.teamId, "team-abc");
|
|
39
|
+
assert.equal(saved.assigneeMe, true);
|
|
40
|
+
assert.equal(saved.envOverrides.stateFilter, false);
|
|
41
|
+
assert.equal(saved.envOverrides.teamId, false);
|
|
42
|
+
const again = getLinearIntakeConfigView();
|
|
43
|
+
assert.deepEqual(again.persisted, saved.persisted);
|
|
44
|
+
// syncEnabled stays untouched when patching picker fields.
|
|
45
|
+
const full = getPersistedLinearIntakeConfig();
|
|
46
|
+
assert.equal(typeof full.syncEnabled, "boolean");
|
|
47
|
+
});
|
|
48
|
+
test("LINEAR_TEAM_ID and LINEAR_STATE_FILTER override effective values and set flags", () => {
|
|
49
|
+
patchLinearIntakeConfig({
|
|
50
|
+
stateFilter: ["Backlog"],
|
|
51
|
+
teamId: "saved-team",
|
|
52
|
+
assigneeMe: false,
|
|
53
|
+
});
|
|
54
|
+
process.env.LINEAR_STATE_FILTER = "Todo, In Review";
|
|
55
|
+
process.env.LINEAR_TEAM_ID = "env-team";
|
|
56
|
+
const view = getLinearIntakeConfigView();
|
|
57
|
+
assert.deepEqual(view.stateFilter, ["Todo", "In Review"]);
|
|
58
|
+
assert.equal(view.teamId, "env-team");
|
|
59
|
+
assert.deepEqual(view.persisted.stateFilter, ["Backlog"]);
|
|
60
|
+
assert.equal(view.persisted.teamId, "saved-team");
|
|
61
|
+
assert.equal(view.envOverrides.stateFilter, true);
|
|
62
|
+
assert.equal(view.envOverrides.teamId, true);
|
|
63
|
+
// A save still writes persisted values; env remains authoritative on read.
|
|
64
|
+
const afterSave = patchLinearIntakeConfig({ stateFilter: ["In Progress"], teamId: "ui-team" });
|
|
65
|
+
assert.deepEqual(afterSave.persisted.stateFilter, ["In Progress"]);
|
|
66
|
+
assert.equal(afterSave.persisted.teamId, "ui-team");
|
|
67
|
+
assert.deepEqual(afterSave.stateFilter, ["Todo", "In Review"]);
|
|
68
|
+
assert.equal(afterSave.teamId, "env-team");
|
|
69
|
+
});
|
|
70
|
+
test("patchLinearIntakeConfig rejects an empty status list", () => {
|
|
71
|
+
assert.throws(() => patchLinearIntakeConfig({ stateFilter: [" ", ""] }), /at least one/i);
|
|
72
|
+
});
|
|
@@ -13,6 +13,7 @@ import { refreshCodexCapacityIfStale } from "../capacity/codex-app-server.js";
|
|
|
13
13
|
import { getCursorTeamBillingSnapshot, refreshCursorTeamBillingIfStale, } from "../capacity/cursor-team.js";
|
|
14
14
|
import { getCursorIndividualBillingSnapshot, refreshCursorIndividualBillingIfStale, refreshCursorIndividualCapacityIfStale, } from "../capacity/cursor-individual.js";
|
|
15
15
|
import { registerVersionRoute } from "./version.js";
|
|
16
|
+
import { registerIntakeLinearConfigRoutes } from "./intake-linear-config.js";
|
|
16
17
|
async function resolveDeckName(deckId) {
|
|
17
18
|
if (!deckId)
|
|
18
19
|
return null;
|
|
@@ -35,15 +36,17 @@ export async function registerRoutes(app) {
|
|
|
35
36
|
const { models, source } = await listRuntimeModels(parsed.data, { refresh });
|
|
36
37
|
return { runtime: parsed.data, models, source };
|
|
37
38
|
});
|
|
39
|
+
// NOT-361: picker config + Linear metadata for the inline New issue filter editor.
|
|
40
|
+
await registerIntakeLinearConfigRoutes(app);
|
|
38
41
|
// Linear lookup for the Issues home "New issue" flow — the only intake surface left
|
|
39
42
|
// after NOT-71 removed the Inbox page and its promote-to-run pipeline.
|
|
43
|
+
// NOT-361: bounded page (≤50, most recently updated) with hasMore escape hatch.
|
|
40
44
|
app.get("/api/intake/linear", async (_req, reply) => {
|
|
41
45
|
try {
|
|
42
|
-
|
|
43
|
-
return { candidates };
|
|
46
|
+
return await listLinearCandidates();
|
|
44
47
|
}
|
|
45
48
|
catch (e) {
|
|
46
|
-
return reply.status(502).send({ error: String(e), candidates: [] });
|
|
49
|
+
return reply.status(502).send({ error: String(e), candidates: [], hasMore: false });
|
|
47
50
|
}
|
|
48
51
|
});
|
|
49
52
|
/** Free-form kick lookup: `?q=NOT-103` or a Linear issue URL. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { LinearIntakeConfigPatch } from "@agent-dealer/shared";
|
|
2
|
+
import { fetchLinearIntakeMetadata, } from "../adapters/linear-inbox.js";
|
|
3
|
+
import { LinearApiKeyMissingError } from "../adapters/linear-graphql.js";
|
|
4
|
+
import { getLinearIntakeConfigView, patchLinearIntakeConfig, } from "../repository/intake-settings.js";
|
|
5
|
+
/**
|
|
6
|
+
* NOT-361: Linear picker config + metadata for the inline New issue filter editor.
|
|
7
|
+
* Local dealer API (same trust model as repository-mappings); Linear auth is the API key.
|
|
8
|
+
*/
|
|
9
|
+
export async function registerIntakeLinearConfigRoutes(app) {
|
|
10
|
+
app.get("/api/intake/linear/config", async () => getLinearIntakeConfigView());
|
|
11
|
+
app.patch("/api/intake/linear/config", async (req, reply) => {
|
|
12
|
+
try {
|
|
13
|
+
const patch = LinearIntakeConfigPatch.parse(req.body ?? {});
|
|
14
|
+
return patchLinearIntakeConfig(patch);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return reply.status(400).send({ error: e instanceof Error ? e.message : String(e) });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
app.get("/api/intake/linear/metadata", async (_req, reply) => {
|
|
21
|
+
try {
|
|
22
|
+
return await fetchLinearIntakeMetadata();
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof LinearApiKeyMissingError) {
|
|
26
|
+
return reply.status(503).send({ error: e.message });
|
|
27
|
+
}
|
|
28
|
+
return reply.status(502).send({ error: e instanceof Error ? e.message : String(e) });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -32,19 +32,44 @@ before(() => {
|
|
|
32
32
|
process.env.LINEAR_API_KEY = "test-key";
|
|
33
33
|
globalThis.fetch = (async (_url, init) => {
|
|
34
34
|
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
35
|
-
const
|
|
36
|
-
|
|
35
|
+
const query = body.query ?? "";
|
|
36
|
+
let payload;
|
|
37
|
+
if (typeof body.variables?.id === "string") {
|
|
38
|
+
payload = {
|
|
37
39
|
data: {
|
|
38
40
|
issue: LIST_NODES.find((n) => n.identifier === body.variables?.id) ??
|
|
39
41
|
LIST_NODES.find((n) => n.identifier === "NOT-242") ??
|
|
40
42
|
null,
|
|
41
43
|
},
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
else if (query.includes("IntakeMetadata") || /query\s*\{\s*viewer\s*\{/.test(query)) {
|
|
47
|
+
payload = {
|
|
44
48
|
data: {
|
|
45
|
-
|
|
49
|
+
viewer: { id: "viewer-1", name: "Ada" },
|
|
50
|
+
teams: {
|
|
51
|
+
nodes: [
|
|
52
|
+
{
|
|
53
|
+
id: "team-1",
|
|
54
|
+
name: "Core",
|
|
55
|
+
key: "COR",
|
|
56
|
+
states: { nodes: [{ name: "Todo", type: "unstarted" }] },
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
46
60
|
},
|
|
47
61
|
};
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
payload = {
|
|
65
|
+
data: {
|
|
66
|
+
issues: {
|
|
67
|
+
nodes: LIST_NODES,
|
|
68
|
+
pageInfo: { hasNextPage: listHasMoreForTest, endCursor: null },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
48
73
|
return {
|
|
49
74
|
ok: true,
|
|
50
75
|
status: 200,
|
|
@@ -53,6 +78,8 @@ before(() => {
|
|
|
53
78
|
};
|
|
54
79
|
});
|
|
55
80
|
});
|
|
81
|
+
/** Flipped by hasMore route tests; default false for the existing candidate-list cases. */
|
|
82
|
+
let listHasMoreForTest = false;
|
|
56
83
|
after(() => {
|
|
57
84
|
globalThis.fetch = realFetch;
|
|
58
85
|
delete process.env.LINEAR_API_KEY;
|
|
@@ -63,11 +90,13 @@ async function buildApp() {
|
|
|
63
90
|
return app;
|
|
64
91
|
}
|
|
65
92
|
test("GET /api/intake/linear returns candidates with resolved repo hints", async () => {
|
|
93
|
+
listHasMoreForTest = false;
|
|
66
94
|
const app = await buildApp();
|
|
67
95
|
const res = await app.inject({ method: "GET", url: "/api/intake/linear" });
|
|
68
96
|
assert.equal(res.statusCode, 200);
|
|
69
97
|
const json = res.json();
|
|
70
98
|
assert.equal(json.candidates.length, 4);
|
|
99
|
+
assert.equal(json.hasMore, false);
|
|
71
100
|
const one = json.candidates.find((c) => c.identifier === "NOT-242");
|
|
72
101
|
assert.equal(one?.repoResolution?.status, "resolved");
|
|
73
102
|
assert.equal(one?.repoResolution?.repository, "github.com/not-so-fat/agent-dealer");
|
|
@@ -143,3 +172,64 @@ test("a mapped Linear label resolves in direct lookup", async () => {
|
|
|
143
172
|
replaceRepositoryMappings({ mappings: [] });
|
|
144
173
|
}
|
|
145
174
|
});
|
|
175
|
+
// NOT-361: config / metadata / hasMore on the intake routes.
|
|
176
|
+
test("GET/PATCH /api/intake/linear/config round-trips picker filters", async () => {
|
|
177
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
178
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
179
|
+
const app = await buildApp();
|
|
180
|
+
const patched = await app.inject({
|
|
181
|
+
method: "PATCH",
|
|
182
|
+
url: "/api/intake/linear/config",
|
|
183
|
+
payload: { stateFilter: ["Todo"], teamId: "team-1", assigneeMe: true },
|
|
184
|
+
});
|
|
185
|
+
assert.equal(patched.statusCode, 200);
|
|
186
|
+
const body = patched.json();
|
|
187
|
+
assert.deepEqual(body.persisted, { stateFilter: ["Todo"], teamId: "team-1", assigneeMe: true });
|
|
188
|
+
const got = await app.inject({ method: "GET", url: "/api/intake/linear/config" });
|
|
189
|
+
assert.equal(got.statusCode, 200);
|
|
190
|
+
assert.deepEqual(got.json().persisted, body.persisted);
|
|
191
|
+
});
|
|
192
|
+
test("env overrides are visible on the config view", async () => {
|
|
193
|
+
process.env.LINEAR_STATE_FILTER = "In Review";
|
|
194
|
+
process.env.LINEAR_TEAM_ID = "env-team";
|
|
195
|
+
try {
|
|
196
|
+
const app = await buildApp();
|
|
197
|
+
const res = await app.inject({ method: "GET", url: "/api/intake/linear/config" });
|
|
198
|
+
assert.equal(res.statusCode, 200);
|
|
199
|
+
const body = res.json();
|
|
200
|
+
assert.deepEqual(body.stateFilter, ["In Review"]);
|
|
201
|
+
assert.equal(body.teamId, "env-team");
|
|
202
|
+
assert.equal(body.envOverrides.stateFilter, true);
|
|
203
|
+
assert.equal(body.envOverrides.teamId, true);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
delete process.env.LINEAR_STATE_FILTER;
|
|
207
|
+
delete process.env.LINEAR_TEAM_ID;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
test("GET /api/intake/linear/metadata returns teams, statuses, and viewer", async () => {
|
|
211
|
+
const app = await buildApp();
|
|
212
|
+
const res = await app.inject({ method: "GET", url: "/api/intake/linear/metadata" });
|
|
213
|
+
assert.equal(res.statusCode, 200);
|
|
214
|
+
const body = res.json();
|
|
215
|
+
assert.equal(body.teams[0]?.name, "Core");
|
|
216
|
+
assert.ok(body.workflowStates.some((s) => s.name === "Todo"));
|
|
217
|
+
assert.equal(body.viewer?.name, "Ada");
|
|
218
|
+
});
|
|
219
|
+
test("GET /api/intake/linear reports hasMore true and false", async () => {
|
|
220
|
+
// Ensure assigneeMe does not short-circuit the list when viewer lookup fails.
|
|
221
|
+
const app = await buildApp();
|
|
222
|
+
await app.inject({
|
|
223
|
+
method: "PATCH",
|
|
224
|
+
url: "/api/intake/linear/config",
|
|
225
|
+
payload: { assigneeMe: false },
|
|
226
|
+
});
|
|
227
|
+
listHasMoreForTest = true;
|
|
228
|
+
const more = await app.inject({ method: "GET", url: "/api/intake/linear" });
|
|
229
|
+
assert.equal(more.statusCode, 200);
|
|
230
|
+
assert.equal(more.json().hasMore, true);
|
|
231
|
+
listHasMoreForTest = false;
|
|
232
|
+
const done = await app.inject({ method: "GET", url: "/api/intake/linear" });
|
|
233
|
+
assert.equal(done.statusCode, 200);
|
|
234
|
+
assert.equal(done.json().hasMore, false);
|
|
235
|
+
});
|