toolroll 0.9.59 → 0.9.61
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/CHANGELOG.md +26 -0
- package/README.md +8 -6
- package/dist/agent-board.d.ts +208 -0
- package/dist/agent-board.js +454 -0
- package/dist/agent-fence.d.ts +19 -0
- package/dist/agent-fence.js +100 -8
- package/dist/agent-pulse.d.ts +43 -0
- package/dist/agent-pulse.js +214 -0
- package/dist/agent-turns.d.ts +52 -0
- package/dist/agent-turns.js +137 -0
- package/dist/agents-board.d.ts +101 -0
- package/dist/agents-board.js +225 -0
- package/dist/assignment-adapters.d.ts +2 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +14 -5
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +10 -2
- package/dist/assignment.js +21 -14
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +29 -25
- package/dist/browser-crew.js +2 -0
- package/dist/browser-workspace.d.ts +79 -1
- package/dist/browser-workspace.js +1 -0
- package/dist/builder.d.ts +8 -0
- package/dist/builder.js +178 -29
- package/dist/chat-channel.js +10 -1
- package/dist/chat-core.d.ts +6 -0
- package/dist/chat-core.js +8 -0
- package/dist/chat-delivery.js +22 -0
- package/dist/chat-messages.js +8 -1
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +2 -0
- package/dist/chat-voice.js +2 -0
- package/dist/checkout-cleanup.d.ts +3 -0
- package/dist/checkout-cleanup.js +11 -2
- package/dist/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- package/dist/contracts/console-api.d.ts +3 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/contracts/lead-tools.d.ts +16 -2
- package/dist/contracts/lead-tools.js +5 -1
- package/dist/design/tokens.css +234 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord.js +2 -1
- package/dist/evidence-pack.js +1 -1
- package/dist/evidence.d.ts +28 -0
- package/dist/evidence.js +87 -17
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +229 -10
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- package/dist/invoke.js +110 -18
- package/dist/landed-cleanup.d.ts +157 -0
- package/dist/landed-cleanup.js +707 -0
- package/dist/lead-cli.js +22 -19
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +49 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +48 -8
- package/dist/live-input.d.ts +68 -0
- package/dist/live-input.js +254 -0
- package/dist/live.d.ts +1 -1
- package/dist/live.js +4 -2
- package/dist/mailbox.js +3 -2
- package/dist/maintenance.d.ts +2 -0
- package/dist/maintenance.js +5 -2
- package/dist/mcp-core.js +1 -1
- package/dist/operate-remote-arguments.js +5 -1
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +201 -19
- package/dist/orphan-sweep.d.ts +2 -0
- package/dist/orphan-sweep.js +5 -3
- package/dist/project-delete-ui.d.ts +11 -0
- package/dist/project-delete-ui.js +29 -2
- package/dist/project-tools.d.ts +7 -11
- package/dist/project-tools.js +13 -37
- package/dist/provider.d.ts +73 -8
- package/dist/provider.js +87 -17
- package/dist/result-completion.d.ts +7 -0
- package/dist/result-completion.js +9 -0
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +3 -3
- package/dist/serve.js +22 -3
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- package/dist/server/live.d.ts +3 -0
- package/dist/server/live.js +35 -2
- package/dist/server/pages.js +59 -35
- package/dist/server/render-chat.js +14 -2
- package/dist/server/render-tasks.d.ts +14 -2
- package/dist/server/render-tasks.js +38 -20
- package/dist/server/route-table.js +4 -1
- package/dist/server/settings.js +2 -1
- package/dist/server/task-views.js +10 -0
- package/dist/server/tasks.js +63 -0
- package/dist/shared-deps.d.ts +9 -4
- package/dist/shared-deps.js +89 -13
- package/dist/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- package/dist/staged-runtimes.d.ts +16 -4
- package/dist/staged-runtimes.js +160 -38
- package/dist/steer-live.d.ts +19 -0
- package/dist/steer-live.js +93 -0
- package/dist/steer-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +25 -0
- package/dist/steer-status.js +44 -0
- package/dist/storage-sweep-child.d.ts +1 -0
- package/dist/storage-sweep-child.js +76 -0
- package/dist/storage-sweep-process.d.ts +41 -0
- package/dist/storage-sweep-process.js +187 -0
- package/dist/storage-sweep.d.ts +30 -6
- package/dist/storage-sweep.js +214 -28
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +16 -4
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +35 -0
- package/dist/store.d.ts +202 -2
- package/dist/store.js +440 -13
- package/dist/surface.js +5 -3
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- package/dist/sweep-quarantine.d.ts +28 -0
- package/dist/sweep-quarantine.js +153 -0
- package/dist/task-status.d.ts +2 -0
- package/dist/task-status.js +5 -1
- package/dist/team-updates.js +3 -3
- package/dist/test-temp.d.ts +6 -3
- package/dist/test-temp.js +15 -9
- package/dist/toolroll-update.js +15 -2
- package/dist/work-index.d.ts +1 -0
- package/dist/work-index.js +32 -12
- package/dist/work-summary.d.ts +2 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +3 -1
- package/dist/worktree.js +93 -10
- package/package.json +21 -21
- package/scripts/provider-canary.mjs +235 -147
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agents board's projection (one place to check on running agents): each
|
|
3
|
+
* task's newest build attempt from the last day, in the viewer's admitted
|
|
4
|
+
* projects, grouped Needs you, Working, Stuck, Done (not viewed yet), Idle.
|
|
5
|
+
* A protocol repair never takes the row: while one runs, its parent's row says
|
|
6
|
+
* "Fixing the handoff" and keeps the parent's transcript, changes and link.
|
|
7
|
+
* Saved facts only (agent-board.ts, the run, its activity and its sealed diff
|
|
8
|
+
* stats); nothing here verifies evidence or grants an action.
|
|
9
|
+
*/
|
|
10
|
+
import { quietSince, stuckSince } from "./agent-board.js";
|
|
11
|
+
import { activityLine } from "./activity-line.js";
|
|
12
|
+
import { readVerifiedArtifact } from "./evidence.js";
|
|
13
|
+
import { failedAttemptSentence } from "./needs-you.js";
|
|
14
|
+
import { projectName } from "./project.js";
|
|
15
|
+
import { providerName } from "./provider-auth.js";
|
|
16
|
+
import { runActivityOf } from "./task-activity.js";
|
|
17
|
+
export const AGENT_GROUPS = [
|
|
18
|
+
{ key: "needs-you", label: "Needs you" },
|
|
19
|
+
{ key: "working", label: "Working" },
|
|
20
|
+
{ key: "stuck", label: "Stuck" },
|
|
21
|
+
{ key: "done", label: "Done" },
|
|
22
|
+
{ key: "idle", label: "Idle" },
|
|
23
|
+
];
|
|
24
|
+
/** Finished attempts stay on the board this long; unread Done and Needs you stay three days. */
|
|
25
|
+
const RECENT_MS = 24 * 60 * 60_000;
|
|
26
|
+
const KEPT_MS = 3 * RECENT_MS;
|
|
27
|
+
const BOARD_LIMIT = 80;
|
|
28
|
+
const str = (row, key) => row[key] == null ? null : String(row[key]);
|
|
29
|
+
const FACTS = `SELECT r.id, r.task_ref, r.provider, r.runner, r.outcome, r.reason, r.started_at, r.finished_at, r.phase, r.attempt_key IS NULL AS legacy,
|
|
30
|
+
tr.external_id AS task_id, tr.repo, t.title, t.state AS task_state,
|
|
31
|
+
n.state AS turn, n.state_at, n.transcript_at, n.stuck_at, n.said, n.said_note, n.said_at, n.diff_files, n.tool_kind, n.tool_at, n.question_note,
|
|
32
|
+
EXISTS (SELECT 1 FROM run p WHERE p.parent_run = r.id AND p.role = 'repair' AND p.outcome IS NULL) AS repairing,
|
|
33
|
+
(SELECT json_object('id', q.id, 'key', q.key, 'text', q.question) FROM agent_question q
|
|
34
|
+
WHERE q.run = r.id AND q.closed_at IS NULL AND q.attempt_key = r.attempt_key ORDER BY q.id LIMIT 1) AS question,
|
|
35
|
+
EXISTS (SELECT 1 FROM agent_seen s WHERE s.account = $account AND s.run = r.id) AS seen
|
|
36
|
+
FROM run r
|
|
37
|
+
JOIN task_ref tr ON tr.id = r.task_ref AND tr.backend = 'built-in' AND tr.repo IS NOT NULL
|
|
38
|
+
JOIN task t ON t.id = tr.external_id
|
|
39
|
+
LEFT JOIN agent_now n ON n.task_ref = r.task_ref AND n.run = r.id AND n.attempt_key = r.attempt_key
|
|
40
|
+
WHERE r.role = 'builder'`;
|
|
41
|
+
function readFacts(row) {
|
|
42
|
+
let question = null;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = row["question"] == null ? null : JSON.parse(String(row["question"]));
|
|
45
|
+
question = parsed === null ? null : { id: Number(parsed.id), key: String(parsed.key), text: String(parsed.text) };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
question = null;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
runId: Number(row["id"]), taskRef: Number(row["task_ref"]), taskId: String(row["task_id"]), title: String(row["title"]), repo: str(row, "repo"),
|
|
52
|
+
provider: String(row["provider"] ?? "claude"), runner: String(row["runner"]), outcome: str(row, "outcome"), reason: str(row, "reason"),
|
|
53
|
+
startedAt: String(row["started_at"]), finishedAt: str(row, "finished_at"), legacy: Number(row["legacy"]) === 1, taskState: String(row["task_state"]),
|
|
54
|
+
turn: str(row, "turn"), stateAt: str(row, "state_at"), transcriptAt: str(row, "transcript_at"), stuckAt: str(row, "stuck_at"),
|
|
55
|
+
said: str(row, "said"), saidNote: str(row, "said_note"), saidAt: str(row, "said_at"), diffFiles: row["diff_files"] == null ? null : Number(row["diff_files"]),
|
|
56
|
+
toolKind: str(row, "tool_kind"), toolAt: str(row, "tool_at"), phase: str(row, "phase"), questionNote: str(row, "question_note"), repairing: Number(row["repairing"]) === 1,
|
|
57
|
+
question, seen: Number(row["seen"]) === 1,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The board's candidate builds, through the partial indexes (agent_board_open, agent_board_recent): open ones and
|
|
62
|
+
* those finished since $kept, one per task (its newest), never a scan of every run. A candidate a newer build of the
|
|
63
|
+
* same task has replaced is dropped by an indexed probe, so the row is still the task's newest build.
|
|
64
|
+
*/
|
|
65
|
+
export const BOARD_CANDIDATES = `WITH candidate AS (
|
|
66
|
+
SELECT task_ref, id FROM run WHERE role = 'builder' AND outcome IS NULL
|
|
67
|
+
UNION ALL
|
|
68
|
+
SELECT task_ref, id FROM run WHERE role = 'builder' AND outcome IS NOT NULL AND finished_at >= $kept
|
|
69
|
+
), newest AS (SELECT task_ref, MAX(id) AS id FROM candidate GROUP BY task_ref)`;
|
|
70
|
+
/** The board's candidate attempts: each task's newest build, open or recently finished, in the admitted projects. */
|
|
71
|
+
export function agentFacts(store, now, access, account) {
|
|
72
|
+
return store.handle.prepare(agentFactsSql())
|
|
73
|
+
.all({ $account: account, $kept: new Date(now.getTime() - KEPT_MS).toISOString(), $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []) })
|
|
74
|
+
.map(readFacts);
|
|
75
|
+
}
|
|
76
|
+
/** The board's query, exported for its query-plan test. */
|
|
77
|
+
export function agentFactsSql() {
|
|
78
|
+
return `${BOARD_CANDIDATES}
|
|
79
|
+
${FACTS.replace("FROM run r", "FROM newest c JOIN run r ON r.id = c.id")}
|
|
80
|
+
AND NOT EXISTS (SELECT 1 FROM run x WHERE x.task_ref = r.task_ref AND x.role = 'builder' AND x.id > r.id)
|
|
81
|
+
AND NOT EXISTS (SELECT 1 FROM task_ref c WHERE c.backend = tr.backend AND c.revision_of = tr.external_id AND c.repo IS tr.repo)
|
|
82
|
+
AND ($all = 1 OR tr.repo IN (SELECT value FROM json_each($repos)))
|
|
83
|
+
ORDER BY r.id DESC LIMIT ${BOARD_LIMIT}`;
|
|
84
|
+
}
|
|
85
|
+
/** One exact build attempt, whatever its age (the opened row), in the admitted projects: a repair opens as its parent. */
|
|
86
|
+
export function agentFactsFor(store, run, access, account) {
|
|
87
|
+
const row = store.handle.prepare(`${FACTS} AND r.id = (SELECT CASE WHEN role = 'repair' AND parent_run IS NOT NULL THEN parent_run ELSE id END FROM run WHERE id = $run) AND ($all = 1 OR tr.repo IN (SELECT value FROM json_each($repos)))`)
|
|
88
|
+
.get({ $account: account, $run: run, $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []) });
|
|
89
|
+
return row === undefined ? null : readFacts(row);
|
|
90
|
+
}
|
|
91
|
+
const FINISHED_WELL = new Set(["built", "no-change"]);
|
|
92
|
+
const SETTLED_TASK = new Set(["done", "cancelled"]);
|
|
93
|
+
/** Where an attempt sits on the board, and its one state word. Stuck is worked out from the facts now, so fresh
|
|
94
|
+
* transcript or activity clears it at once (the pulse's saved mark only tells open boards it crossed). */
|
|
95
|
+
export function agentGroupOf(facts, activityAt, now) {
|
|
96
|
+
if (facts.outcome === null) {
|
|
97
|
+
if (facts.question !== null)
|
|
98
|
+
return { group: "needs-you", label: "Question" };
|
|
99
|
+
if (facts.repairing)
|
|
100
|
+
return { group: "working", label: "Working" };
|
|
101
|
+
if (facts.turn === "crashed")
|
|
102
|
+
return { group: "needs-you", label: "Stopped unexpectedly" };
|
|
103
|
+
if (facts.said === "blocked" && facts.turn !== "idle")
|
|
104
|
+
return { group: "needs-you", label: "Blocked" };
|
|
105
|
+
if (facts.turn === "busy" && stuckSince({ state: "busy", stateAt: facts.stateAt ?? facts.startedAt, transcriptAt: facts.transcriptAt ?? facts.startedAt, toolAt: facts.toolAt }, activityAt, now) !== null) {
|
|
106
|
+
return { group: "stuck", label: "Stuck" };
|
|
107
|
+
}
|
|
108
|
+
// Its turn has its result, but the build is still capturing and checking it: still at work, not idle.
|
|
109
|
+
return { group: "working", label: "Working" };
|
|
110
|
+
}
|
|
111
|
+
if (FINISHED_WELL.has(facts.outcome)) {
|
|
112
|
+
const label = facts.outcome === "built" ? "Done" : "No changes";
|
|
113
|
+
// Unread only for attempts the board saw start: a run from before it never counts as news.
|
|
114
|
+
return { group: facts.seen || facts.legacy ? "idle" : "done", label };
|
|
115
|
+
}
|
|
116
|
+
if (SETTLED_TASK.has(facts.taskState))
|
|
117
|
+
return { group: "idle", label: facts.outcome === "interrupted" ? "Stopped" : "Failed" };
|
|
118
|
+
return { group: "needs-you", label: facts.outcome === "parked" ? "Needs a decision" : facts.outcome === "interrupted" ? "Stopped" : facts.outcome === "refused" ? "Not started" : "Failed" };
|
|
119
|
+
}
|
|
120
|
+
const sealed = new Map();
|
|
121
|
+
/** A finished attempt's sealed diff stats (cached by artifact: sealed bytes never change). */
|
|
122
|
+
export function sealedDiffOf(store, root, run) {
|
|
123
|
+
if (root === undefined)
|
|
124
|
+
return null;
|
|
125
|
+
const artifact = store.artifactsFor(run).find(one => one.kind === "diff-stat");
|
|
126
|
+
if (artifact === undefined || artifact.captureStatus !== "ok")
|
|
127
|
+
return null;
|
|
128
|
+
if (sealed.has(artifact.id))
|
|
129
|
+
return sealed.get(artifact.id) ?? null;
|
|
130
|
+
let summary = null;
|
|
131
|
+
try {
|
|
132
|
+
const read = readVerifiedArtifact(root, artifact);
|
|
133
|
+
if (read.ok) {
|
|
134
|
+
const stat = JSON.parse(read.content.toString("utf8"));
|
|
135
|
+
const files = Array.isArray(stat.files) ? stat.files.filter(one => typeof one?.["path"] === "string").map(one => ({
|
|
136
|
+
path: String(one["path"]), additions: typeof one["additions"] === "number" ? one["additions"] : null, deletions: typeof one["deletions"] === "number" ? one["deletions"] : null
|
|
137
|
+
})) : [];
|
|
138
|
+
summary = { fileCount: Number(stat.fileCount ?? files.length) || 0, additions: Number(stat.additions ?? 0) || 0, deletions: Number(stat.deletions ?? 0) || 0, files };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
summary = null;
|
|
143
|
+
}
|
|
144
|
+
if (sealed.size > 500)
|
|
145
|
+
sealed.clear();
|
|
146
|
+
sealed.set(artifact.id, summary);
|
|
147
|
+
return summary;
|
|
148
|
+
}
|
|
149
|
+
const files = (count) => `${count} ${count === 1 ? "file" : "files"}`;
|
|
150
|
+
/** A row's diff size: the sealed stats once finished, the pulse's count while it runs. */
|
|
151
|
+
export function diffWords(facts, diff) {
|
|
152
|
+
if (facts.outcome !== null) {
|
|
153
|
+
if (diff === null)
|
|
154
|
+
return null;
|
|
155
|
+
if (diff.fileCount === 0)
|
|
156
|
+
return "No changes";
|
|
157
|
+
return `+${diff.additions} −${diff.deletions} · ${files(diff.fileCount)}`;
|
|
158
|
+
}
|
|
159
|
+
if (facts.diffFiles === null)
|
|
160
|
+
return null;
|
|
161
|
+
return facts.diffFiles === 0 ? "No changes yet" : `${files(facts.diffFiles)} changed`;
|
|
162
|
+
}
|
|
163
|
+
const SETTLING_PHASES = new Set(["validating-handoff", "capturing-evidence", "committing", "verifying-proof", "correcting-proof"]);
|
|
164
|
+
/** Reasons whose words only restate the Failed chip: the row shows the agent's own last word instead, or nothing. */
|
|
165
|
+
const RESTATES_FAILED = new Set(["agent"]);
|
|
166
|
+
const minutes = (ms) => Math.max(1, Math.floor(ms / 60_000));
|
|
167
|
+
/** A finished attempt's one line: never the state again (the chip says it), only why. */
|
|
168
|
+
function endedLine(facts) {
|
|
169
|
+
const own = facts.said === "blocked" || facts.said === "done" ? facts.saidNote : null;
|
|
170
|
+
if (FINISHED_WELL.has(facts.outcome ?? ""))
|
|
171
|
+
return facts.said === "done" ? facts.saidNote : null;
|
|
172
|
+
if (facts.outcome === "parked")
|
|
173
|
+
return "Stopped to ask a question";
|
|
174
|
+
if (RESTATES_FAILED.has(facts.reason?.trim() ?? ""))
|
|
175
|
+
return own;
|
|
176
|
+
return failedAttemptSentence(facts.reason);
|
|
177
|
+
}
|
|
178
|
+
/** One board row. */
|
|
179
|
+
export function agentRowOf(store, facts, now, root) {
|
|
180
|
+
const activity = facts.outcome === null ? runActivityOf(store, { id: facts.runId, runner: facts.runner, startedAt: facts.startedAt }, now) : null;
|
|
181
|
+
const { group, label } = agentGroupOf(facts, activity?.at ?? null, now);
|
|
182
|
+
let line, quiet = false;
|
|
183
|
+
if (facts.outcome !== null)
|
|
184
|
+
line = endedLine(facts);
|
|
185
|
+
else if (facts.question !== null)
|
|
186
|
+
line = facts.question.text;
|
|
187
|
+
else if (facts.repairing)
|
|
188
|
+
line = "Fixing the handoff";
|
|
189
|
+
else if (group === "stuck")
|
|
190
|
+
line = `No new output for ${Math.max(4, minutes(now.getTime() - quietSince({ stateAt: facts.stateAt ?? facts.startedAt, transcriptAt: facts.transcriptAt ?? facts.startedAt, toolAt: facts.toolAt }, activity.at)))} min`;
|
|
191
|
+
else if (facts.turn === "idle" || (facts.turn !== "crashed" && SETTLING_PHASES.has(facts.phase ?? "")))
|
|
192
|
+
line = "Checking the result";
|
|
193
|
+
else if (facts.turn === "busy" && facts.toolAt !== null && now.getTime() - Date.parse(facts.toolAt) >= 60_000) {
|
|
194
|
+
line = `${facts.toolKind === "command" ? "Running a command" : "Waiting on a tool"} · ${minutes(now.getTime() - Date.parse(facts.toolAt))} min`;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const said = facts.saidNote !== null && facts.saidAt !== null && facts.saidAt >= activity.at ? facts.saidNote : null;
|
|
198
|
+
const read = activityLine(activity, now.getTime());
|
|
199
|
+
line = said ?? read.text;
|
|
200
|
+
quiet = said === null && read.quiet;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
runId: facts.runId, taskId: facts.taskId, title: facts.title, project: facts.repo === null ? null : projectName(facts.repo), href: `/agents?run=${facts.runId}`,
|
|
204
|
+
group, label, line, quiet, startedAt: facts.startedAt, endedAt: facts.finishedAt,
|
|
205
|
+
diff: diffWords(facts, facts.outcome === null ? null : sealedDiffOf(store, root, facts.runId)), agent: providerName(facts.provider),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** The whole board: groups in order, rows newest first within each; empty groups dropped. */
|
|
209
|
+
export function agentsBoardOf(store, now, access, account, root) {
|
|
210
|
+
const recent = new Date(now.getTime() - RECENT_MS).toISOString();
|
|
211
|
+
const rows = agentFacts(store, now, access, account).map(facts => ({ facts, row: agentRowOf(store, facts, now, root) }))
|
|
212
|
+
.filter(({ facts, row }) => facts.outcome === null || row.group === "done" || row.group === "needs-you" || (facts.finishedAt ?? "") >= recent)
|
|
213
|
+
.map(({ row }) => row);
|
|
214
|
+
return AGENT_GROUPS.map(group => ({ ...group, rows: rows.filter(row => row.group === group.key) })).filter(group => group.rows.length > 0);
|
|
215
|
+
}
|
|
216
|
+
/** A board fingerprint for one exact build: what its open view shows moved, its repairs included. */
|
|
217
|
+
export function agentRunFingerprint(store, run) {
|
|
218
|
+
const row = store.handle.prepare(`SELECT r.outcome, r.finished_at, r.phase, n.state, n.state_at, n.transcript_bytes, n.stuck_at, n.said_at, n.diff_files, n.tool_at, n.question_note,
|
|
219
|
+
(SELECT count(*) || ':' || coalesce(max(id), 0) || ':' || coalesce(sum(closed_at IS NULL), 0) FROM agent_question WHERE run = r.id) AS asks,
|
|
220
|
+
(SELECT count(*) || ':' || coalesce(max(id), 0) || ':' || coalesce(sum(outcome IS NULL), 0) FROM run p WHERE p.parent_run = r.id AND p.role = 'repair') AS repairs,
|
|
221
|
+
(SELECT at FROM run_activity WHERE run = r.id) AS activity,
|
|
222
|
+
(SELECT count(*) || ':' || coalesce(max(id), 0) FROM task_steer WHERE task_ref = r.task_ref) AS steer
|
|
223
|
+
FROM run r LEFT JOIN agent_now n ON n.run = r.id AND n.attempt_key = r.attempt_key WHERE r.id = ?`).get(run);
|
|
224
|
+
return row === undefined ? null : Object.values(row).map(value => String(value)).join("|");
|
|
225
|
+
}
|
|
@@ -18,7 +18,7 @@ export declare const ASSIGNMENT_TOOLS: readonly [{
|
|
|
18
18
|
}, {
|
|
19
19
|
readonly name: "list_assignment_updates";
|
|
20
20
|
readonly operation: "updates";
|
|
21
|
-
readonly description: "Read
|
|
21
|
+
readonly description: "Read the history of assignment handoffs sent to people in your projects, after a cursor. History only: it delivers and acknowledges nothing. To receive your updates, use get_assignment_inbox.";
|
|
22
22
|
}, {
|
|
23
23
|
readonly name: "claim_assignment";
|
|
24
24
|
readonly operation: "claim";
|
|
@@ -83,6 +83,7 @@ export declare function assignmentForCoordinator(store: Store, token: string, op
|
|
|
83
83
|
at: string;
|
|
84
84
|
digest: string;
|
|
85
85
|
lead?: true;
|
|
86
|
+
landed?: string;
|
|
86
87
|
} | null;
|
|
87
88
|
result: {
|
|
88
89
|
digest: string;
|
|
@@ -16,7 +16,7 @@ const failure = (reason, message) => ({ ok: false, reason, message });
|
|
|
16
16
|
* (src/contracts/gateway-tools.ts), which `toolroll assignment` reads its flags with too. */
|
|
17
17
|
export const ASSIGNMENT_TOOLS = [
|
|
18
18
|
{ name: "get_assignment", operation: "show", description: "Read one assignment's root, current execution, owner, exact result and handoff receipt. Approval, proof and deployment remain separate facts." },
|
|
19
|
-
{ name: "list_assignment_updates", operation: "updates", description: "Read
|
|
19
|
+
{ name: "list_assignment_updates", operation: "updates", description: "Read the history of assignment handoffs sent to people in your projects, after a cursor. History only: it delivers and acknowledges nothing. To receive your updates, use get_assignment_inbox." },
|
|
20
20
|
{ name: "claim_assignment", operation: "claim", description: "Record yourself as this assignment's lead. Repeating your claim is safe; another active owner cannot be replaced. This grants no approval or execution authority." },
|
|
21
21
|
{ name: "acknowledge_assignment", operation: "check", description: "Acknowledge the exact ready-to-check receipt as its lead, using the current receipt digest. This does not accept failed proof, answer decisions, approve work, publish or deploy." },
|
|
22
22
|
{ name: "get_assignment_brief", operation: "brief", description: "Catch up from the local database: current work, decisions, saved results and project knowledge. Bounded read only; no model call or task mutation." },
|
|
@@ -47,10 +47,26 @@ export declare function assignmentInbox(store: Store, owner: AssignmentOwner, qu
|
|
|
47
47
|
consumer: string;
|
|
48
48
|
limit?: number;
|
|
49
49
|
}, now: Date): AssignmentInboxResult;
|
|
50
|
+
/** The built-in lead's inbox: the same page, replay and acknowledgment as a coordinator's, over its follow grant. */
|
|
51
|
+
export declare function leadFollowInbox(store: Store, grant: {
|
|
52
|
+
id: number;
|
|
53
|
+
repos: readonly string[];
|
|
54
|
+
}, query: {
|
|
55
|
+
consumer: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
}, now: Date): AssignmentInboxResult;
|
|
50
58
|
/** ACK advances only the exact issued page, atomically. Replaying any prior
|
|
51
59
|
* ACK is harmless, including while a newer page awaits acknowledgment. */
|
|
52
60
|
export declare function acknowledgeAssignmentDelivery(store: Store, owner: AssignmentOwner, query: {
|
|
53
61
|
consumer: string;
|
|
54
62
|
batchId: string;
|
|
55
63
|
}, now: Date): AssignmentDeliveryAckResult;
|
|
64
|
+
/** The built-in lead acknowledges its own page the same way, once it has handled it. */
|
|
65
|
+
export declare function acknowledgeLeadFollowDelivery(store: Store, grant: {
|
|
66
|
+
id: number;
|
|
67
|
+
repos: readonly string[];
|
|
68
|
+
}, query: {
|
|
69
|
+
consumer: string;
|
|
70
|
+
batchId: string;
|
|
71
|
+
}, now: Date): AssignmentDeliveryAckResult;
|
|
56
72
|
export {};
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
/** Durable, explicitly acknowledged delivery of assignment status events.
|
|
2
2
|
* Callers authenticate the raw coordinator credential inside the same Store
|
|
3
|
-
* transaction. Delivery receipts never complete tasks or grant authority.
|
|
3
|
+
* transaction. Delivery receipts never complete tasks or grant authority.
|
|
4
|
+
* This is the one way results reach a lead: an external coordinator reads its
|
|
5
|
+
* owned assignments' events, and the built-in lead reads its follow grant's
|
|
6
|
+
* (lead-follow.ts) through the same saved pages, replay and acknowledgment. */
|
|
4
7
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
-
import { ASSIGNMENT_STATUS_ACTION, parseAssignmentStatusEvent } from "./assignment-status.js";
|
|
8
|
+
import { ASSIGNMENT_STATUS_ACTION, followActor, parseAssignmentStatusEvent } from "./assignment-status.js";
|
|
6
9
|
const MAX_PAGE_BYTES = 64 * 1024;
|
|
7
10
|
const fail = (reason, message) => ({ ok: false, reason, message });
|
|
8
|
-
const actor = (owner) => `coordinator:${owner.id}`;
|
|
9
|
-
const keyFor = (owner, consumer) => createHash("sha256").update(JSON.stringify([owner.id, consumer])).digest("hex");
|
|
10
11
|
const validConsumer = (value) => typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value);
|
|
11
12
|
const ownerSql = `COALESCE((SELECT 'lead:'||o.lead FROM team_task_owner o WHERE o.task_ref=t.id),(SELECT a.actor FROM action_ledger a WHERE a.task_id = t.external_id
|
|
12
13
|
AND a.action = 'assignment claimed' AND a.source = 'work' ORDER BY a.id DESC LIMIT 1),
|
|
13
14
|
'coordinator:' || t.coordinator_cid)`;
|
|
15
|
+
function coordinatorReader(store, owner) {
|
|
16
|
+
const actor = `coordinator:${owner.id}`;
|
|
17
|
+
return { actor, key: consumer => createHash("sha256").update(JSON.stringify([owner.id, consumer])).digest("hex"),
|
|
18
|
+
repos: () => admittedRepos(store, owner), admits: { sql: `${ownerSql} = ?`, params: [actor] },
|
|
19
|
+
receives: event => event.assignment.owner?.id === owner.id, gone: "The coordinator is no longer active." };
|
|
20
|
+
}
|
|
21
|
+
/** The built-in lead's follow grant (lead-follow.ts), which its caller has already proved, with the projects it covers. */
|
|
22
|
+
function followReader(grant) {
|
|
23
|
+
return { actor: followActor(grant.id), key: consumer => createHash("sha256").update(JSON.stringify(["follow", grant.id, consumer])).digest("hex"),
|
|
24
|
+
repos: () => [...grant.repos], admits: { sql: "1 = 1", params: [] }, receives: () => true, gone: "Automatic crew updates are off." };
|
|
25
|
+
}
|
|
14
26
|
function admittedRepos(store, owner) {
|
|
15
27
|
if (owner.kind !== "coordinator" || typeof owner.id !== "string" || owner.id.length === 0)
|
|
16
28
|
return null;
|
|
@@ -25,28 +37,28 @@ function admittedRepos(store, owner) {
|
|
|
25
37
|
return null;
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
|
-
function exposedBatch(store,
|
|
40
|
+
function exposedBatch(store, reader, repos, batch) {
|
|
29
41
|
return { ...batch, events: batch.events.map(event => {
|
|
30
42
|
const admitted = repos.includes(event.project) && store.handle.prepare(`SELECT 1 FROM task_ref t
|
|
31
|
-
WHERE t.backend = 'built-in' AND t.external_id = ? AND t.repo = ? AND t.revision_of IS NULL AND ${
|
|
32
|
-
.get(event.rootId, event.project,
|
|
43
|
+
WHERE t.backend = 'built-in' AND t.external_id = ? AND t.repo = ? AND t.revision_of IS NULL AND ${reader.admits.sql}`)
|
|
44
|
+
.get(event.rootId, event.project, ...reader.admits.params) !== undefined;
|
|
33
45
|
return admitted ? event : { id: event.id, unavailable: true };
|
|
34
46
|
}) };
|
|
35
47
|
}
|
|
36
|
-
function eventFromRow(row,
|
|
48
|
+
function eventFromRow(row, reader) {
|
|
37
49
|
const parsed = parseAssignmentStatusEvent(String(row["outcome"]));
|
|
38
|
-
if (parsed === null || parsed.assignment.rootId !== row["task_id"] || parsed
|
|
50
|
+
if (parsed === null || parsed.assignment.rootId !== row["task_id"] || !reader.receives(parsed))
|
|
39
51
|
return null;
|
|
40
52
|
return { id: Number(row["id"]), createdAt: String(row["at"]), rootId: String(row["task_id"]),
|
|
41
53
|
project: String(row["repo"]), kind: "assignment-status", digest: parsed.digest, assignment: parsed.assignment };
|
|
42
54
|
}
|
|
43
|
-
function pendingBatch(store,
|
|
55
|
+
function pendingBatch(store, reader, key) {
|
|
44
56
|
const id = store.serviceCursor(`assignment-inbox:${key}:pending`);
|
|
45
57
|
if (id === 0)
|
|
46
58
|
return null;
|
|
47
59
|
const invalid = () => fail("unavailable", "The saved delivery page is invalid; its cursor has not advanced.");
|
|
48
60
|
const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE id = ? AND actor = ? AND action = ? AND source = 'work'")
|
|
49
|
-
.get(id, actor
|
|
61
|
+
.get(id, reader.actor, `assignment delivery batch:${key}`);
|
|
50
62
|
if (!row || Buffer.byteLength(String(row["outcome"])) > MAX_PAGE_BYTES)
|
|
51
63
|
return invalid();
|
|
52
64
|
let parsed;
|
|
@@ -69,8 +81,8 @@ function pendingBatch(store, owner, key) {
|
|
|
69
81
|
if (!event || !Number.isSafeInteger(event.id) || event.id <= previous)
|
|
70
82
|
return invalid();
|
|
71
83
|
const source = store.handle.prepare("SELECT id,at,task_id,repo,outcome FROM action_ledger WHERE id = ? AND actor = ? AND action = ? AND source = 'work'")
|
|
72
|
-
.get(event.id, actor
|
|
73
|
-
const original = source === undefined ? null : eventFromRow(source,
|
|
84
|
+
.get(event.id, reader.actor, ASSIGNMENT_STATUS_ACTION);
|
|
85
|
+
const original = source === undefined ? null : eventFromRow(source, reader);
|
|
74
86
|
if (original === null || JSON.stringify(original) !== JSON.stringify(event))
|
|
75
87
|
return invalid();
|
|
76
88
|
previous = event.id;
|
|
@@ -83,31 +95,38 @@ function pendingBatch(store, owner, key) {
|
|
|
83
95
|
* tombstone; it does not prevent acknowledgment of that already issued page.
|
|
84
96
|
* Consumer names identify independent inboxes. */
|
|
85
97
|
export function assignmentInbox(store, owner, query, now) {
|
|
98
|
+
return inbox(store, coordinatorReader(store, owner), query, now);
|
|
99
|
+
}
|
|
100
|
+
/** The built-in lead's inbox: the same page, replay and acknowledgment as a coordinator's, over its follow grant. */
|
|
101
|
+
export function leadFollowInbox(store, grant, query, now) {
|
|
102
|
+
return inbox(store, followReader(grant), query, now);
|
|
103
|
+
}
|
|
104
|
+
function inbox(store, reader, query, now) {
|
|
86
105
|
if (!validConsumer(query.consumer) || (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 100)))
|
|
87
106
|
return fail("invalid-input", "Use a consumer name of 1–64 characters and a page size of 1–100.");
|
|
88
107
|
return store.transact(() => store.savepoint(() => {
|
|
89
|
-
const repos =
|
|
108
|
+
const repos = reader.repos();
|
|
90
109
|
if (repos === null)
|
|
91
|
-
return fail("unavailable",
|
|
92
|
-
const key =
|
|
93
|
-
const after = store.serviceCursor(cursorKey), pending = pendingBatch(store,
|
|
110
|
+
return fail("unavailable", reader.gone);
|
|
111
|
+
const key = reader.key(query.consumer), cursorKey = `assignment-inbox:${key}:acked`;
|
|
112
|
+
const after = store.serviceCursor(cursorKey), pending = pendingBatch(store, reader, key);
|
|
94
113
|
if (pending !== null) {
|
|
95
114
|
if ("ok" in pending)
|
|
96
115
|
return pending;
|
|
97
116
|
if (pending.consumer !== query.consumer || pending.after !== after)
|
|
98
117
|
return fail("unavailable", "The saved delivery page does not match its cursor.");
|
|
99
|
-
return { ok: true, batch: exposedBatch(store,
|
|
118
|
+
return { ok: true, batch: exposedBatch(store, reader, repos, pending), replayed: true };
|
|
100
119
|
}
|
|
101
120
|
const limit = query.limit ?? 10;
|
|
102
121
|
const rows = store.handle.prepare(`SELECT n.id,n.at,n.task_id,n.repo,n.outcome
|
|
103
122
|
FROM action_ledger n JOIN task_ref t ON t.backend = 'built-in'
|
|
104
123
|
AND t.external_id = n.task_id AND t.repo = n.repo AND t.revision_of IS NULL
|
|
105
124
|
WHERE n.id > ? AND n.action = ? AND n.source = 'work' AND n.actor = ?
|
|
106
|
-
AND n.repo IN (SELECT value FROM json_each(?)) AND ${
|
|
107
|
-
ORDER BY n.id LIMIT ?`).all(after, ASSIGNMENT_STATUS_ACTION, actor
|
|
125
|
+
AND n.repo IN (SELECT value FROM json_each(?)) AND ${reader.admits.sql}
|
|
126
|
+
ORDER BY n.id LIMIT ?`).all(after, ASSIGNMENT_STATUS_ACTION, reader.actor, JSON.stringify(repos), ...reader.admits.params, limit + 1);
|
|
108
127
|
const batch = { version: 1, id: null, consumer: query.consumer, after, nextCursor: after, hasMore: rows.length > limit, events: [] };
|
|
109
128
|
for (const row of rows.slice(0, limit)) {
|
|
110
|
-
const event = eventFromRow(row,
|
|
129
|
+
const event = eventFromRow(row, reader);
|
|
111
130
|
if (event === null)
|
|
112
131
|
return fail("unavailable", "The next saved assignment event is invalid; no event was skipped.");
|
|
113
132
|
batch.events.push(event);
|
|
@@ -123,7 +142,7 @@ export function assignmentInbox(store, owner, query, now) {
|
|
|
123
142
|
if (batch.events.length === 0)
|
|
124
143
|
return { ok: true, batch, replayed: false };
|
|
125
144
|
batch.id = randomBytes(16).toString("hex");
|
|
126
|
-
const id = store.recordAction({ at: now.toISOString(), actor: actor
|
|
145
|
+
const id = store.recordAction({ at: now.toISOString(), actor: reader.actor, repo: null, taskId: null, runId: null,
|
|
127
146
|
action: `assignment delivery batch:${key}`, outcome: JSON.stringify(batch), source: "work" });
|
|
128
147
|
store.setServiceCursor(`assignment-inbox:${key}:pending`, id, now);
|
|
129
148
|
return { ok: true, batch, replayed: false };
|
|
@@ -132,26 +151,33 @@ export function assignmentInbox(store, owner, query, now) {
|
|
|
132
151
|
/** ACK advances only the exact issued page, atomically. Replaying any prior
|
|
133
152
|
* ACK is harmless, including while a newer page awaits acknowledgment. */
|
|
134
153
|
export function acknowledgeAssignmentDelivery(store, owner, query, now) {
|
|
154
|
+
return acknowledge(store, coordinatorReader(store, owner), query, now);
|
|
155
|
+
}
|
|
156
|
+
/** The built-in lead acknowledges its own page the same way, once it has handled it. */
|
|
157
|
+
export function acknowledgeLeadFollowDelivery(store, grant, query, now) {
|
|
158
|
+
return acknowledge(store, followReader(grant), query, now);
|
|
159
|
+
}
|
|
160
|
+
function acknowledge(store, reader, query, now) {
|
|
135
161
|
if (!validConsumer(query.consumer) || typeof query.batchId !== "string" || !/^[a-f0-9]{32}$/.test(query.batchId))
|
|
136
162
|
return fail("invalid-input", "Use the exact consumer and batch ID returned by the inbox.");
|
|
137
163
|
return store.transact(() => store.savepoint(() => {
|
|
138
|
-
const repos =
|
|
164
|
+
const repos = reader.repos();
|
|
139
165
|
if (repos === null)
|
|
140
|
-
return fail("unavailable",
|
|
141
|
-
const key =
|
|
166
|
+
return fail("unavailable", reader.gone);
|
|
167
|
+
const key = reader.key(query.consumer), cursorKey = `assignment-inbox:${key}:acked`;
|
|
142
168
|
const cursor = store.serviceCursor(cursorKey), action = `assignment delivery acknowledged:${key}`;
|
|
143
169
|
const acknowledged = store.handle.prepare(`SELECT 1 FROM action_ledger WHERE actor = ? AND action = ? AND source = 'work'
|
|
144
|
-
AND json_extract(outcome, '$.batchId') = ? LIMIT 1`).get(actor
|
|
170
|
+
AND json_extract(outcome, '$.batchId') = ? LIMIT 1`).get(reader.actor, action, query.batchId);
|
|
145
171
|
if (acknowledged)
|
|
146
172
|
return { ok: true, alreadyAcknowledged: true, cursor };
|
|
147
|
-
const pending = pendingBatch(store,
|
|
173
|
+
const pending = pendingBatch(store, reader, key);
|
|
148
174
|
if (pending !== null && "ok" in pending)
|
|
149
175
|
return pending;
|
|
150
176
|
if (pending === null || pending.id !== query.batchId || pending.consumer !== query.consumer || pending.after !== cursor)
|
|
151
177
|
return fail("stale-batch", "This batch was not issued to this inbox; its cursor has not advanced.");
|
|
152
178
|
// Access loss redacts replayed events; the exact issued page remains
|
|
153
179
|
// acknowledgeable so its inaccessible entries cannot wedge this inbox.
|
|
154
|
-
store.recordAction({ at: now.toISOString(), actor: actor
|
|
180
|
+
store.recordAction({ at: now.toISOString(), actor: reader.actor, repo: null, taskId: null, runId: null,
|
|
155
181
|
action, outcome: JSON.stringify({ batchId: pending.id, after: pending.after, cursor: pending.nextCursor }), source: "work" });
|
|
156
182
|
store.setServiceCursor(cursorKey, pending.nextCursor, now);
|
|
157
183
|
store.setServiceCursor(`assignment-inbox:${key}:pending`, 0, now);
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
2
|
import { type AssignmentSnapshot } from "./assignment.js";
|
|
3
3
|
export declare const ASSIGNMENT_STATUS_ACTION = "assignment status observed";
|
|
4
|
+
/** A reader's first sight of a task that is not news: the state later events are compared to, never delivered. */
|
|
5
|
+
export declare const ASSIGNMENT_STATUS_BASELINE = "assignment status baseline";
|
|
4
6
|
export declare const ASSIGNMENT_STATUS_SOURCE: "work";
|
|
7
|
+
/** The states that wake a lead: a result to check, a question, completion, or cancellation. */
|
|
8
|
+
export declare const LEAD_WAKE_STATES: readonly AssignmentSnapshot["state"][];
|
|
9
|
+
/** The built-in lead's events, one stream per follow grant. */
|
|
10
|
+
export declare const followActor: (grant: number) => string;
|
|
5
11
|
/** No saved artifact contents, credential bytes, or polling timestamps. Exact
|
|
6
12
|
* IDs and digests identify the full read; display text is deliberately bounded. */
|
|
7
13
|
export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
|
|
@@ -17,7 +23,7 @@ export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
|
|
|
17
23
|
} | null;
|
|
18
24
|
runId: number | null;
|
|
19
25
|
primaryAction: {
|
|
20
|
-
code: "confirm-stopped" | "inspect-decisions" | "inspect-run" | "inspect-stop" | "inspect-task" | "open-pr" | "reconcile-run" | "sign-in" | import("./dispatch.js").DispatchAction;
|
|
26
|
+
code: "answer-agent" | "confirm-stopped" | "inspect-decisions" | "inspect-run" | "inspect-stop" | "inspect-task" | "open-pr" | "reconcile-run" | "sign-in" | import("./dispatch.js").DispatchAction;
|
|
21
27
|
label: string;
|
|
22
28
|
target: import("./work-summary.js").WorkTarget;
|
|
23
29
|
} | null;
|
|
@@ -42,13 +48,12 @@ export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
|
|
|
42
48
|
remoteState: string | null;
|
|
43
49
|
} | null;
|
|
44
50
|
};
|
|
51
|
+
/** An owner's events always name it; the built-in lead's follow events name whatever owner the task has, or none. */
|
|
45
52
|
export type AssignmentStatusEvent = {
|
|
46
53
|
version: 1;
|
|
47
54
|
digest: string;
|
|
48
55
|
stateDigest: string;
|
|
49
|
-
assignment: ReturnType<typeof assignmentStatusBrief
|
|
50
|
-
owner: NonNullable<ReturnType<typeof assignmentStatusBrief>["owner"]>;
|
|
51
|
-
};
|
|
56
|
+
assignment: ReturnType<typeof assignmentStatusBrief>;
|
|
52
57
|
};
|
|
53
58
|
/** An unreadable ledger event is an explicit consumer error, not a cursor that
|
|
54
59
|
* may silently skip the row. This validates the bounded data, not authority. */
|
|
@@ -57,6 +62,10 @@ export declare function parseAssignmentStatusEvent(value: string): AssignmentSta
|
|
|
57
62
|
* for worker reconciliation. The latest event, not a permanent digest set,
|
|
58
63
|
* deduplicates retries so Working → Needs decision → Working is retained. */
|
|
59
64
|
export declare function noteAssignmentStatus(store: Store, snapshot: AssignmentSnapshot, now: Date): number | null;
|
|
65
|
+
/** The built-in lead's crew update (lead-follow.ts): the same event, under its follow grant. The
|
|
66
|
+
* caller has already proved the grant and its projects; the root must still be a placed task. */
|
|
67
|
+
export declare function noteFollowStatus(store: Store, grant: number, snapshot: AssignmentSnapshot, now: Date, action?: typeof ASSIGNMENT_STATUS_ACTION | typeof ASSIGNMENT_STATUS_BASELINE): number | null;
|
|
60
68
|
/** At most fifty roots per pass; the cursor survives process restarts. A full
|
|
61
|
-
* pass cycles back, so changes below the cursor are eventually observed too.
|
|
69
|
+
* pass cycles back, so changes below the cursor are eventually observed too.
|
|
70
|
+
* A pass is skipped when nothing moved since the cycle began (sweep-gate.ts). */
|
|
62
71
|
export declare function syncAssignmentStatuses(store: Store, now: Date, repos: readonly string[], root?: string): void;
|