toolroll 0.9.85 → 0.9.86
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 +24 -0
- package/dist/agent-fence.d.ts +6 -2
- package/dist/agent-fence.js +3 -2
- package/dist/assignment.js +4 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +31 -29
- package/dist/browser-workspace.d.ts +40 -0
- package/dist/chat-actions.d.ts +11 -1
- package/dist/chat-actions.js +53 -1
- package/dist/chat-channel.js +1 -0
- package/dist/chat-quiet.js +3 -2
- package/dist/claim.d.ts +3 -0
- package/dist/claim.js +4 -1
- package/dist/contracts/chat-actions.d.ts +9 -0
- package/dist/contracts/chat-actions.js +3 -0
- package/dist/contracts/console-api.d.ts +4 -1
- package/dist/contracts/console-api.js +4 -1
- package/dist/contracts/flow-propose.d.ts +4 -1
- package/dist/contracts/flow-propose.js +1 -1
- package/dist/contracts/flow.d.ts +41 -0
- package/dist/contracts/flow.js +4 -2
- package/dist/contracts/lead-tools.d.ts +21 -0
- package/dist/contracts/lead-tools.js +2 -0
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/contracts/trigger-payloads.d.ts +20 -0
- package/dist/contracts/trigger-payloads.js +4 -0
- package/dist/coordinator.js +1 -0
- package/dist/deploy-gate.js +4 -0
- package/dist/dispatch.d.ts +3 -0
- package/dist/dispatch.js +4 -1
- package/dist/flow-browser.d.ts +103 -0
- package/dist/flow-browser.js +204 -0
- package/dist/flow-code.d.ts +5 -1
- package/dist/flow-code.js +4 -1
- package/dist/flow-engine.js +16 -0
- package/dist/flow-live.js +9 -1
- package/dist/flow-pull-request.js +15 -7
- package/dist/flow-share.js +3 -1
- package/dist/flow-steps.d.ts +9 -1
- package/dist/flow-steps.js +55 -5
- package/dist/flow-triggers.js +55 -0
- package/dist/flows-cli.d.ts +19 -0
- package/dist/flows-cli.js +47 -2
- package/dist/flows-ui.js +11 -2
- package/dist/flows.js +5 -2
- package/dist/lead-tools.js +35 -6
- package/dist/lead-voice.js +16 -5
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +215 -6
- package/dist/project-delete.js +7 -3
- package/dist/pull-request-flow.js +35 -4
- package/dist/result-follow-ups.js +4 -0
- package/dist/scope.d.ts +5 -0
- package/dist/scope.js +7 -0
- package/dist/server/chat.js +4 -2
- package/dist/server/console.css +29 -0
- package/dist/server/flows.js +15 -0
- package/dist/server/render-pages.d.ts +13 -0
- package/dist/server/render-pages.js +45 -2
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +17 -6
- package/dist/server/route-table.js +11 -0
- package/dist/server/task-views.js +5 -0
- package/dist/server/tasks.d.ts +10 -0
- package/dist/server/tasks.js +54 -2
- package/dist/settings-proofs.js +1 -0
- package/dist/store.d.ts +71 -3
- package/dist/store.js +222 -20
- package/dist/surface.js +18 -2
- package/dist/task-archive.d.ts +10 -0
- package/dist/task-archive.js +16 -0
- package/dist/task-control.d.ts +1 -1
- package/dist/tidy.d.ts +130 -0
- package/dist/tidy.js +503 -0
- package/dist/work-index.d.ts +24 -0
- package/dist/work-index.js +52 -30
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +3 -2
- package/package.json +1 -1
package/dist/lead-voice.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { actorLabel, withActor } from "./actor.js";
|
|
9
9
|
import { chatControlHref } from "./chat-controls.js";
|
|
10
10
|
import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
|
|
11
|
-
import { BUILT_IN } from "./store.js";
|
|
11
|
+
import { BUILT_IN, refArchived } from "./store.js";
|
|
12
12
|
import { leadNameOf } from "./lead-identity.js";
|
|
13
13
|
import { leadLapsed } from "./task-status.js";
|
|
14
14
|
import { phoneText as plain } from "./telegram-status.js";
|
|
@@ -85,19 +85,24 @@ export function leadSay(store, actor, text, taskId, now) {
|
|
|
85
85
|
// The task's own id never reaches a chat: it reads as the task's short title (chat-voice.ts).
|
|
86
86
|
const said = taskId === null ? text.trim() : chatText(text.trim(), [{ id: taskId, title: chatTitle(store, taskId) }]);
|
|
87
87
|
return store.transact(() => {
|
|
88
|
+
// A put-away task (v145) gets no messages: the lead's words about it would nudge a person about nothing.
|
|
89
|
+
if (task !== null && refArchived(store, task.refId))
|
|
90
|
+
return { ok: false, reason: "refused", message: "This task is put away. A person can restore it first." };
|
|
88
91
|
const stamp = now.toISOString();
|
|
89
92
|
const ledger = store.recordAction({ at: stamp, actor: actorLabel(actor), repo: task?.repo ?? null, taskId, runId: null,
|
|
90
93
|
action: LEAD_SAID, outcome: "recorded", source: "work", detail: text.trim() });
|
|
91
|
-
const open = store.handle.prepare(`SELECT id, dedupe_key, body FROM notification WHERE kind = ? AND recipient = ? ORDER BY id DESC LIMIT 1`)
|
|
94
|
+
const open = store.handle.prepare(`SELECT id, dedupe_key, body, task_ref FROM notification WHERE kind = ? AND recipient = ? ORDER BY id DESC LIMIT 1`)
|
|
92
95
|
.get(LEAD_SAY_KIND, actor.account);
|
|
93
96
|
const opened = open === undefined ? null : /^lead-say:(.+):\d+$/.exec(String(open["dedupe_key"]))?.[1] ?? null;
|
|
94
|
-
|
|
97
|
+
// A batch is about one task (or none): words about another start a row of their own, so each row keeps its task.
|
|
98
|
+
const sameTask = open !== undefined && (open["task_ref"] ?? null) === (task === null ? null : task.refId);
|
|
99
|
+
const joined = sameTask && opened !== null && Date.parse(opened) > now.getTime() - BATCH_MS && Date.parse(opened) <= now.getTime();
|
|
95
100
|
const lines = [...(joined ? String(open["body"]).split("\n") : []), said].slice(-BATCH_LINES);
|
|
96
101
|
if (joined)
|
|
97
102
|
store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(stamp, Number(open["id"]));
|
|
98
103
|
const key = `lead-say:${joined ? opened : stamp}:${ledger}`;
|
|
99
104
|
store.enqueueNotification({ dedupeKey: key, kind: LEAD_SAY_KIND, subject: leadNameOf(store, actor.account), body: lines.join("\n"),
|
|
100
|
-
recipient: actor.account, source: { installation: true }, ...(taskId === null ? {} : { link: chatControlHref("task", taskId) }) }, now);
|
|
105
|
+
recipient: actor.account, source: task === null ? { installation: true } : { taskRef: task.refId }, ...(taskId === null ? {} : { link: chatControlHref("task", taskId) }) }, now);
|
|
101
106
|
const row = store.handle.prepare("SELECT id FROM notification WHERE dedupe_key = ?").get(key);
|
|
102
107
|
return { ok: true, joined, notification: row === undefined ? null : Number(row["id"]) };
|
|
103
108
|
});
|
|
@@ -136,14 +141,20 @@ export function leadClaim(store, actor, taskId, now) {
|
|
|
136
141
|
if ("ok" in task)
|
|
137
142
|
return task;
|
|
138
143
|
const { root } = familyOf(store, taskId);
|
|
139
|
-
store.transact(() => {
|
|
144
|
+
const taken = store.transact(() => {
|
|
145
|
+
// A put-away task (v145) waits on nobody, so the lead takes nothing on there: checked with the claim's own write.
|
|
146
|
+
if (refArchived(store, task.refId))
|
|
147
|
+
return false;
|
|
140
148
|
const ledger = store.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: task.repo, taskId: root, runId: null,
|
|
141
149
|
action: LEAD_CLAIMED, outcome: "recorded", source: "work" });
|
|
142
150
|
// The owner's card for this task repaints to "<name> is on it": quiet chat edits its card, and every-update
|
|
143
151
|
// chat edits the attempt's own card (the Failed alert), or follows it up when that message is gone.
|
|
144
152
|
store.enqueueNotification({ dedupeKey: `lead-on-it:${ledger}`, kind: LEAD_ON_IT_KIND, subject: `${leadNameOf(store, actor.account)} is on it`, body: "",
|
|
145
153
|
link: chatControlHref("task", taskId), source: cardSource(store, root) ?? { taskRef: task.refId } }, now);
|
|
154
|
+
return true;
|
|
146
155
|
});
|
|
156
|
+
if (!taken)
|
|
157
|
+
return { ok: false, reason: "refused", message: "This task is put away. A person can restore it first." };
|
|
147
158
|
return { ok: true, root, claim: leadClaimOf(store, taskId, now, actor.account) };
|
|
148
159
|
}
|
|
149
160
|
/** Where a task's chat card lives: the family's newest attempt (the card every-update chat edits), else its newest
|
package/dist/operate.d.ts
CHANGED
|
@@ -115,7 +115,7 @@ type Args = {
|
|
|
115
115
|
* exports are behavior, not commentary — and the declared command guide
|
|
116
116
|
* (surface.ts) is tested for exact equality against them.
|
|
117
117
|
*/
|
|
118
|
-
export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask", "checks", "add-tests"];
|
|
118
|
+
export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask", "checks", "add-tests", "archive", "restore", "tidy"];
|
|
119
119
|
export declare const PUBLISH_ACTIONS: readonly ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
|
|
120
120
|
export declare const CONFIG_ACTIONS: readonly ["show", "set", "clear"];
|
|
121
121
|
export declare const APPROVER_ACTIONS: readonly ["list", "add"];
|
package/dist/operate.js
CHANGED
|
@@ -28,6 +28,8 @@ import { idleStorageSweep, sweepSchedule, sweepScheduleWords } from "./storage-s
|
|
|
28
28
|
import { lastSweep, saveSweep, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
|
|
29
29
|
import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
|
|
30
30
|
import { cleanLandedBacklog, gitOf, landedBacklog, reconcileLanded, BACKLOG_DAYS } from "./landed-cleanup.js";
|
|
31
|
+
import { accessOf, applyTidy, approverStandsNow, withinLens, familyRepoOf, releaseFactsFor, runTidy, tidyDays, tidyDigest, tidyGroups, tidyPreview, TIDY_DEFAULT_DAYS, TIDY_PASS_LIMIT } from "./tidy.js";
|
|
32
|
+
import { personProof } from "./person-proof.js";
|
|
31
33
|
import { RemoteMains } from "./remote-main.js";
|
|
32
34
|
import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
|
|
33
35
|
import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
|
|
@@ -105,7 +107,7 @@ import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
|
|
|
105
107
|
import { homedir, hostname } from "node:os";
|
|
106
108
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
107
109
|
import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
|
|
108
|
-
import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
|
|
110
|
+
import { openStore, openStoreNoMigrate, archiveTaskFamily, restoreTaskFamily, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
|
|
109
111
|
import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
|
|
110
112
|
import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
|
|
111
113
|
import { authorizePlanUnderMode } from "./plan-auto.js";
|
|
@@ -273,7 +275,11 @@ export const OPERATE_HELP = `toolroll — operating the queue
|
|
|
273
275
|
the lead passes it as --token, or TOOLROLL_LEAD_TOKEN, and its work pings nobody
|
|
274
276
|
toolroll task ask <id> --person <name> --why "<text>"
|
|
275
277
|
hand a task to a person: it pings them, and it is theirs
|
|
276
|
-
toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status
|
|
278
|
+
toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status (--view archived: put away)
|
|
279
|
+
toolroll task archive <id...> [--reason <why>] put tasks away: out of every list, history kept (a person's act)
|
|
280
|
+
toolroll task restore <id...> bring put-away tasks back exactly as they were
|
|
281
|
+
toolroll task tidy --preview [--days N] what Put away old tasks would put away, and why (read-only)
|
|
282
|
+
toolroll task tidy --apply --digest <d> [--except <id,...>] put away exactly that list
|
|
277
283
|
toolroll task show <id>
|
|
278
284
|
toolroll task wait <id> [--timeout <seconds>]
|
|
279
285
|
wait for this attempt to finish or need a person
|
|
@@ -663,7 +669,7 @@ export const TASK_ACTIONS = [
|
|
|
663
669
|
"add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign",
|
|
664
670
|
"reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
|
|
665
671
|
"review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask",
|
|
666
|
-
"checks", "add-tests",
|
|
672
|
+
"checks", "add-tests", "archive", "restore", "tidy",
|
|
667
673
|
];
|
|
668
674
|
export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
|
|
669
675
|
export const CONFIG_ACTIONS = ["show", "set", "clear"];
|
|
@@ -707,6 +713,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
|
|
|
707
713
|
"starter",
|
|
708
714
|
// flows: templates, steps, scripts and cards.
|
|
709
715
|
"template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone", "option",
|
|
716
|
+
// task tidy --apply: the previewed tasks to leave out.
|
|
717
|
+
"except",
|
|
710
718
|
"token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
|
|
711
719
|
// pings follow responsibility: replacements.
|
|
712
720
|
"replaced-by", "replaces",
|
|
@@ -757,6 +765,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
|
757
765
|
"password-stdin",
|
|
758
766
|
// task steer: pause the running Claude build, then send the note.
|
|
759
767
|
"now",
|
|
768
|
+
// task tidy: look first (--preview), then put away exactly what was shown (--apply --digest).
|
|
769
|
+
"preview", "apply",
|
|
760
770
|
]);
|
|
761
771
|
/** `project network` reads --remove <hosts> and --local any|none as values, where every other verb's --remove and
|
|
762
772
|
* --local are switches. */
|
|
@@ -1564,6 +1574,8 @@ function claimCommand(positional, flags, context) {
|
|
|
1564
1574
|
return fail(write, json, "claim", "sweep-limit", `its sweep already has ${result.limit} running at once — it starts when one finishes`, EXIT.refused, { sweep: result.sweep, limit: result.limit });
|
|
1565
1575
|
if (result.reason === "watch-custody")
|
|
1566
1576
|
return fail(write, json, "claim", "watch-custody", "This worker no longer holds the project; restart the watch", EXIT.refused);
|
|
1577
|
+
if (result.reason === "archived")
|
|
1578
|
+
return fail(write, json, "claim", "archived", `${id} is put away — restore it with \`toolroll task restore ${id}\` first`, EXIT.refused);
|
|
1567
1579
|
if (result.reason === "draining")
|
|
1568
1580
|
return fail(write, json, "claim", "draining", `Toolroll is restarting (draining): ${runner} takes no new work until it is back (by ${result.until})`, EXIT.refused, { until: result.until });
|
|
1569
1581
|
return fail(write, json, "claim", result.reason, `held by ${result.by} until ${result.until}`, EXIT.refused, { holder: result.by, until: result.until });
|
|
@@ -3562,6 +3574,13 @@ async function reconcileCommand(flags, context) {
|
|
|
3562
3574
|
remotes.close();
|
|
3563
3575
|
}
|
|
3564
3576
|
const landedGone = landed.steps.filter(one => one.outcome === "removed");
|
|
3577
|
+
// Tidy (task-tidy.md): old routine runs a newer run replaces, release tasks already published or replaced, and CI
|
|
3578
|
+
// notices main has fixed are put away, at most a bounded number a pass, each with its reason in the ledger.
|
|
3579
|
+
let tidied = [];
|
|
3580
|
+
try {
|
|
3581
|
+
tidied = runTidy(store, clock(), releaseFactsFor(context.databaseFile), TIDY_PASS_LIMIT, { principal: "operator", repos: [repo], includeUnplaced: false });
|
|
3582
|
+
}
|
|
3583
|
+
catch { /* the next pass tries again */ }
|
|
3565
3584
|
// Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
|
|
3566
3585
|
// the task is complete or cancelled); their branches stay. Each removal is in the ledger.
|
|
3567
3586
|
// Completing or cancelling a task stops anything still running from its checkout first.
|
|
@@ -3605,6 +3624,7 @@ async function reconcileCommand(flags, context) {
|
|
|
3605
3624
|
slimmed.slimmed.length === 0 &&
|
|
3606
3625
|
landed.completed.length === 0 &&
|
|
3607
3626
|
landedGone.length === 0 &&
|
|
3627
|
+
tidied.length === 0 &&
|
|
3608
3628
|
liveSwept.removed.length === 0;
|
|
3609
3629
|
return succeed(write, json, "reconcile", {
|
|
3610
3630
|
recovered,
|
|
@@ -3616,6 +3636,7 @@ async function reconcileCommand(flags, context) {
|
|
|
3616
3636
|
processesStopped: [...closedRuns.map(one => ({ run: one.runId, pids: one.done.stopped.map(row => row.pid) })), ...closedCheckouts.map(one => ({ checkout: one.path, pids: one.done.stopped.map(row => row.pid) }))],
|
|
3617
3637
|
checkoutsSlimmed: slimmed.slimmed.map(one => one.path),
|
|
3618
3638
|
landed: { completed: landed.completed, steps: landed.steps },
|
|
3639
|
+
tidied: tidied.map(one => ({ id: one.taskId, rule: one.rule, reason: one.reason })),
|
|
3619
3640
|
liveViewsSwept: liveSwept.removed.length,
|
|
3620
3641
|
retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
|
|
3621
3642
|
storageSweep: storageSwept,
|
|
@@ -3630,6 +3651,7 @@ async function reconcileCommand(flags, context) {
|
|
|
3630
3651
|
...closedRuns.map(one => `Run #${one.runId} ended; stopped ${stoppedWords(one.done)}.`),
|
|
3631
3652
|
...closedCheckouts.map(one => `${one.path}: its task finished; stopped ${stoppedWords(one.done)}.`),
|
|
3632
3653
|
...(landed.completed.length === 0 ? [] : [`Marked ${landed.completed.length} landed result(s) complete: ${landed.completed.join(", ")}.`]),
|
|
3654
|
+
...(tidied.length === 0 ? [] : [`Put away ${tidied.length} old task(s): ${tidied.map(one => one.taskId).join(", ")}.`]),
|
|
3633
3655
|
...(landedGone.length === 0 ? [] : [`Removed landed work: ${landedGone.filter(one => one.kind === "checkout").length} checkout(s), ${landedGone.filter(one => one.kind === "branch").length} local branch(es).`]),
|
|
3634
3656
|
...(slimmed.slimmed.length === 0 ? [] : [`Dropped dependencies and build output from ${slimmed.slimmed.length} kept checkout(s), about ${bytesWords(slimmed.slimmed.reduce((sum, one) => sum + one.bytes, 0))}.`]),
|
|
3635
3657
|
...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
|
|
@@ -4112,6 +4134,7 @@ async function requeueTask(positional, flags, context) {
|
|
|
4112
4134
|
published: `${id}'s last result is published; revise it through its pull request`,
|
|
4113
4135
|
// Only an Approve & build again asks for an exact build; the CLI never does.
|
|
4114
4136
|
moved: `${id} changed since its page was read; read it again`,
|
|
4137
|
+
archived: `${id} is put away — restore it with \`toolroll task restore ${id}\` first`,
|
|
4115
4138
|
};
|
|
4116
4139
|
return fail(write, json, "task requeue", result.reason, why[result.reason], EXIT.refused);
|
|
4117
4140
|
}
|
|
@@ -8567,6 +8590,12 @@ function taskAction(action, rest, flags, context) {
|
|
|
8567
8590
|
return addTestsTask(rest, flags, context);
|
|
8568
8591
|
case "list":
|
|
8569
8592
|
return listTasks(flags, context);
|
|
8593
|
+
case "archive":
|
|
8594
|
+
return archiveTasksCommand(rest, flags, context);
|
|
8595
|
+
case "restore":
|
|
8596
|
+
return restoreTasksCommand(rest, flags, context);
|
|
8597
|
+
case "tidy":
|
|
8598
|
+
return tidyTasksCommand(rest, flags, context);
|
|
8570
8599
|
case "show":
|
|
8571
8600
|
return showTask(rest, context);
|
|
8572
8601
|
case "wait":
|
|
@@ -8988,9 +9017,9 @@ function listTasks(flags, context) {
|
|
|
8988
9017
|
const limit = rawLimit === undefined ? 40 : Number(rawLimit);
|
|
8989
9018
|
if (wanted !== undefined && !STATES.includes(wanted))
|
|
8990
9019
|
return fail(write, json, 'task list', 'usage', `--state takes one of ${STATES.join(', ')}`, EXIT.usage);
|
|
8991
|
-
if (rawView !== undefined && !['all', 'needs-you', 'running', 'completed'].includes(rawView) ||
|
|
9020
|
+
if (rawView !== undefined && !['all', 'needs-you', 'running', 'completed', 'archived'].includes(rawView) ||
|
|
8992
9021
|
!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
8993
|
-
return fail(write, json, 'task list', 'usage', '--view takes all, needs-you, running or
|
|
9022
|
+
return fail(write, json, 'task list', 'usage', '--view takes all, needs-you, running, completed or archived; --limit takes 1–100', EXIT.usage);
|
|
8994
9023
|
}
|
|
8995
9024
|
// A remote person limited to some projects (remoteLensOf) pages and counts only theirs.
|
|
8996
9025
|
const lens = context.principal?.lens ?? null;
|
|
@@ -9017,7 +9046,8 @@ function listTasks(flags, context) {
|
|
|
9017
9046
|
write(wanted === undefined ? 'The task list is empty.' : `Nothing is ${wanted}.`);
|
|
9018
9047
|
const width = Math.max(0, ...tasks.map(task => task.id.length));
|
|
9019
9048
|
for (const task of tasks)
|
|
9020
|
-
write(` ${task.id.padEnd(width)} ${task.status.label.padEnd(16)} ${task.title}`
|
|
9049
|
+
write(task.archived === undefined ? ` ${task.id.padEnd(width)} ${task.status.label.padEnd(16)} ${task.title}`
|
|
9050
|
+
: ` ${task.id.padEnd(width)} ${'Archived'.padEnd(16)} ${task.title} — by ${task.archived.byKind === 'tidy' ? 'tidy' : task.archived.by}: ${task.archived.reason}`);
|
|
9021
9051
|
if (page.nextCursor !== null)
|
|
9022
9052
|
write(`Next page: --cursor ${page.nextCursor}`);
|
|
9023
9053
|
return EXIT.ok;
|
|
@@ -10149,6 +10179,8 @@ function describeApproveFailure(reason, id) {
|
|
|
10149
10179
|
// Approval policy, phase 1: the lead's token (or a remote API token) proves a credential, not a person.
|
|
10150
10180
|
if (reason === "machine-proof")
|
|
10151
10181
|
return "only a person can approve this — approve it on the task page or from your paired chat";
|
|
10182
|
+
if (reason === "archived")
|
|
10183
|
+
return `${id} is put away — restore it with \`toolroll task restore ${id}\` first`;
|
|
10152
10184
|
if (reason === "second-approver")
|
|
10153
10185
|
return `your approval of ${id} is recorded; it is protected work, so a second person needs to approve it (toolroll task approve ${id} as them)`;
|
|
10154
10186
|
if (reason === "no-approvers") {
|
|
@@ -12754,3 +12786,180 @@ export function consoleIdentitiesInBackground(identities, paths) {
|
|
|
12754
12786
|
export function writeModelRow(store, proved, scope, knownProject, slot, to, now, extra = []) {
|
|
12755
12787
|
return applyModelChoices(store, { scope, change: slot === null ? [] : [{ slot, to }], expected: null, strict: false, knownProject, extra }, { ...proved, live: () => true }, now);
|
|
12756
12788
|
}
|
|
12789
|
+
/** Putting away and restoring are a person's acts (docs/plans/task-tidy.md): never the lead's token, never a remote
|
|
12790
|
+
* API token, never a machine proof. Null when a person is acting. */
|
|
12791
|
+
function refuseMachineTidy(context, command, method) {
|
|
12792
|
+
if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || (method !== undefined && !personProof(method))) {
|
|
12793
|
+
return fail(context.write, context.json, command, "person-only", "Only a person puts tasks away or restores them. Use the console or your own login.", EXIT.refused);
|
|
12794
|
+
}
|
|
12795
|
+
return null;
|
|
12796
|
+
}
|
|
12797
|
+
/** `toolroll task archive <id...> [--reason <text>]`: put tasks away by hand (v145). Each keeps its history, results and
|
|
12798
|
+
* approvals; nothing can run, claim or approve it until it is restored. Credentialed, a person's act. */
|
|
12799
|
+
async function archiveTasksCommand(positional, flags, context) {
|
|
12800
|
+
const { store, write, json, clock } = context;
|
|
12801
|
+
const command = "task archive";
|
|
12802
|
+
const ids = [...new Set(positional)];
|
|
12803
|
+
if (ids.length === 0 || ids.length > 100)
|
|
12804
|
+
return fail(write, json, command, "usage", "`toolroll task archive <id...> [--reason <why>] --as <you> --token <t>` — 1 to 100 tasks", EXIT.usage);
|
|
12805
|
+
const machine = refuseMachineTidy(context, command);
|
|
12806
|
+
if (machine !== null)
|
|
12807
|
+
return machine;
|
|
12808
|
+
const demo = refuseDemo(context, command);
|
|
12809
|
+
if (demo !== null)
|
|
12810
|
+
return demo;
|
|
12811
|
+
const acting = await askCredentials(flags, context);
|
|
12812
|
+
// The password is proved against a project the person can see (the first task's); each task's own project is
|
|
12813
|
+
// re-proved inside its put-away transaction.
|
|
12814
|
+
const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, ids));
|
|
12815
|
+
if (acting === null || !verified?.ok)
|
|
12816
|
+
return refuseApprover(context, command, verified, "putting a task away takes `--as <you> --token <t>` — it is recorded against your name", ids[0]);
|
|
12817
|
+
const personal = refuseMachineTidy(context, command, verified.method);
|
|
12818
|
+
if (personal !== null)
|
|
12819
|
+
return personal;
|
|
12820
|
+
const reason = text(flags, "reason") ?? "Put away by hand";
|
|
12821
|
+
const archived = [], refused = [];
|
|
12822
|
+
for (const id of ids) {
|
|
12823
|
+
const ref = store.lookupRef(id);
|
|
12824
|
+
if (ref === null || store.getTask(id) === null || !store.accountCanAccess(acting.name, ref.repo)) {
|
|
12825
|
+
refused.push({ id, reason: "unknown-task", message: `No task ${id}.` });
|
|
12826
|
+
continue;
|
|
12827
|
+
}
|
|
12828
|
+
const done = archiveTaskFamily(store, id, { by: acting.name, byKind: "person", rule: null, reason }, clock(), { guard: refs => approverStandsNow(store, acting.name, familyRepoOf(store, refs)) });
|
|
12829
|
+
if (done.ok)
|
|
12830
|
+
archived.push({ id, versions: done.refs.length });
|
|
12831
|
+
else
|
|
12832
|
+
refused.push({ id, reason: done.reason, message: done.message });
|
|
12833
|
+
}
|
|
12834
|
+
const lines = () => [
|
|
12835
|
+
...archived.map(one => `Put away ${one.id}${one.versions > 1 ? ` (${one.versions} versions)` : ""}.`),
|
|
12836
|
+
...refused.map(one => `Not put away: ${one.message}`),
|
|
12837
|
+
...(archived.length === 0 ? [] : [`Restore with \`toolroll task restore ${archived.map(one => one.id).join(" ")}\`.`]),
|
|
12838
|
+
];
|
|
12839
|
+
if (refused.length === 0)
|
|
12840
|
+
return succeed(write, json, command, { archived, refused }, lines);
|
|
12841
|
+
if (json)
|
|
12842
|
+
return fail(write, json, command, refused[0].reason, refused[0].message, EXIT.refused, { archived, refused });
|
|
12843
|
+
write(lines().join("\n"));
|
|
12844
|
+
return EXIT.refused;
|
|
12845
|
+
}
|
|
12846
|
+
/** `toolroll task restore <id...>`: bring put-away tasks back exactly as they were (v145). Credentialed, a person's act. */
|
|
12847
|
+
async function restoreTasksCommand(positional, flags, context) {
|
|
12848
|
+
const { store, write, json, clock } = context;
|
|
12849
|
+
const command = "task restore";
|
|
12850
|
+
const ids = [...new Set(positional)];
|
|
12851
|
+
if (ids.length === 0 || ids.length > 100)
|
|
12852
|
+
return fail(write, json, command, "usage", "`toolroll task restore <id...> --as <you> --token <t>` — 1 to 100 tasks", EXIT.usage);
|
|
12853
|
+
const machine = refuseMachineTidy(context, command);
|
|
12854
|
+
if (machine !== null)
|
|
12855
|
+
return machine;
|
|
12856
|
+
const demo = refuseDemo(context, command);
|
|
12857
|
+
if (demo !== null)
|
|
12858
|
+
return demo;
|
|
12859
|
+
const acting = await askCredentials(flags, context);
|
|
12860
|
+
const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, ids));
|
|
12861
|
+
if (acting === null || !verified?.ok)
|
|
12862
|
+
return refuseApprover(context, command, verified, "restoring takes `--as <you> --token <t>` — it is recorded against your name", ids[0]);
|
|
12863
|
+
const personal = refuseMachineTidy(context, command, verified.method);
|
|
12864
|
+
if (personal !== null)
|
|
12865
|
+
return personal;
|
|
12866
|
+
const restored = [], refused = [];
|
|
12867
|
+
for (const id of ids) {
|
|
12868
|
+
const ref = store.lookupRef(id);
|
|
12869
|
+
if (ref === null || store.getTask(id) === null || !store.accountCanAccess(acting.name, ref.repo)) {
|
|
12870
|
+
refused.push({ id, reason: "unknown-task", message: `No task ${id}.` });
|
|
12871
|
+
continue;
|
|
12872
|
+
}
|
|
12873
|
+
const done = restoreTaskFamily(store, id, acting.name, clock(), refs => approverStandsNow(store, acting.name, familyRepoOf(store, refs)));
|
|
12874
|
+
if (done.ok)
|
|
12875
|
+
restored.push(id);
|
|
12876
|
+
else
|
|
12877
|
+
refused.push({ id, reason: done.reason, message: done.message });
|
|
12878
|
+
}
|
|
12879
|
+
const lines = () => [...restored.map(id => `Restored ${id}.`), ...refused.map(one => `Not restored: ${one.message}`)];
|
|
12880
|
+
if (refused.length === 0)
|
|
12881
|
+
return succeed(write, json, command, { restored, refused }, lines);
|
|
12882
|
+
if (json)
|
|
12883
|
+
return fail(write, json, command, refused[0].reason, refused[0].message, EXIT.refused, { restored, refused });
|
|
12884
|
+
write(lines().join("\n"));
|
|
12885
|
+
return EXIT.refused;
|
|
12886
|
+
}
|
|
12887
|
+
/** `toolroll task tidy --preview [--days N]` (read-only, no credential) and `toolroll task tidy --apply --digest <d>
|
|
12888
|
+
* [--days N] [--except <id,...>]` (credentialed, a person's act): the console's Put away old tasks, word for word,
|
|
12889
|
+
* with the same preview and digest. Apply puts away exactly what the preview with that digest showed. */
|
|
12890
|
+
async function tidyTasksCommand(positional, flags, context) {
|
|
12891
|
+
const { store, write, json, clock } = context;
|
|
12892
|
+
const command = "task tidy";
|
|
12893
|
+
const preview = flag(flags, "preview"), apply = flag(flags, "apply");
|
|
12894
|
+
const days = tidyDays(text(flags, "days"));
|
|
12895
|
+
const digest = text(flags, "digest");
|
|
12896
|
+
const except = (text(flags, "except") ?? "").split(",").map(one => one.trim()).filter(one => one !== "");
|
|
12897
|
+
const usage = "`toolroll task tidy --preview [--days N]`, then `toolroll task tidy --apply --digest <d> [--days N] [--except <id,...>]`";
|
|
12898
|
+
if (positional.length > 0 || preview === apply || days === null || (preview && (digest !== undefined || except.length > 0)) || (apply && digest === undefined))
|
|
12899
|
+
return fail(write, json, command, "usage", usage, EXIT.usage);
|
|
12900
|
+
const releases = releaseFactsFor(context.databaseFile);
|
|
12901
|
+
// One scope for the preview and the apply: `--as <you>` previews exactly your projects (no password: it reads), and
|
|
12902
|
+
// the apply reads the same person's projects, so an unchanged list keeps its digest.
|
|
12903
|
+
// Whose lists: `--as <you>`, else this machine's saved login (`toolroll up`), else everything (an unrestricted
|
|
12904
|
+
// person's list; a person limited to some projects previews with --as).
|
|
12905
|
+
const previewAs = text(flags, "as") ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name;
|
|
12906
|
+
const daysArg = `${days === TIDY_DEFAULT_DAYS ? "" : ` --days ${days}`}${previewAs === undefined ? "" : ` --as ${previewAs}`}`;
|
|
12907
|
+
// `--as` only ever narrows: the named person's projects within the caller's own lens, never beyond it.
|
|
12908
|
+
const lens = context.principal?.lens ?? null;
|
|
12909
|
+
const own = { principal: "operator", repos: lens, includeUnplaced: lens === null };
|
|
12910
|
+
const access = previewAs !== undefined ? withinLens(accessOf(store, previewAs), lens) : own;
|
|
12911
|
+
if (preview) {
|
|
12912
|
+
const items = tidyPreview(store, clock(), { releases, days, access });
|
|
12913
|
+
const shown = tidyDigest(items, days);
|
|
12914
|
+
const groups = tidyGroups(items, days);
|
|
12915
|
+
const next = `toolroll task tidy --apply --digest ${shown}${daysArg}`;
|
|
12916
|
+
return succeed(write, json, command, { preview: true, days, digest: shown, count: items.length, groups, next: items.length === 0 ? null : next,
|
|
12917
|
+
...(releases.problem === null ? {} : { releasesSkipped: releases.problem }) }, () => items.length === 0
|
|
12918
|
+
? ["Nothing to put away."]
|
|
12919
|
+
: [`Put away old tasks: ${items.length} task${items.length === 1 ? "" : "s"}.`,
|
|
12920
|
+
...groups.flatMap(group => ["", `${group.label} (${group.items.length})`, ...group.items.map(one => ` ${one.taskId} ${one.reason}${one.members.length > 1 ? ` (${one.members.length} versions)` : ""}`)]),
|
|
12921
|
+
...(releases.problem === null ? [] : ["", `Release tasks were left out: ${releases.problem}.`]),
|
|
12922
|
+
"", `To put these away, run: ${next}`, "Leave some out with --except <id,...>.",
|
|
12923
|
+
...(previewAs === undefined ? ["Limited to some projects? Preview with --as <you> so the list is yours."] : [])]);
|
|
12924
|
+
}
|
|
12925
|
+
const machine = refuseMachineTidy(context, command);
|
|
12926
|
+
if (machine !== null)
|
|
12927
|
+
return machine;
|
|
12928
|
+
const demo = refuseDemo(context, command);
|
|
12929
|
+
if (demo !== null)
|
|
12930
|
+
return demo;
|
|
12931
|
+
const acting = await askCredentials(flags, context);
|
|
12932
|
+
const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, []));
|
|
12933
|
+
if (acting === null || !verified?.ok)
|
|
12934
|
+
return refuseApprover(context, command, verified, "putting tasks away takes `--as <you> --token <t>` — it is recorded against your name", undefined);
|
|
12935
|
+
const personal = refuseMachineTidy(context, command, verified.method);
|
|
12936
|
+
if (personal !== null)
|
|
12937
|
+
return personal;
|
|
12938
|
+
// The person's own view of the lists, from the verified credential (never a flag), within the caller's lens: the
|
|
12939
|
+
// same scope `--preview --as <them>` read.
|
|
12940
|
+
const mine = withinLens(accessOf(store, acting.name), lens);
|
|
12941
|
+
const shownItems = tidyPreview(store, clock(), { releases, days, access: mine });
|
|
12942
|
+
const only = except.length === 0 ? null : shownItems.map(one => one.taskId).filter(id => !except.includes(id));
|
|
12943
|
+
if (except.some(id => !shownItems.some(one => one.taskId === id)))
|
|
12944
|
+
return fail(write, json, command, "outside", "--except names a task that isn't in the list. Check it again with --preview.", EXIT.refused);
|
|
12945
|
+
const done = applyTidy(store, clock(), { by: acting.name, days, digest: digest, only, releases, access: mine });
|
|
12946
|
+
if (!done.ok) {
|
|
12947
|
+
const again = `toolroll task tidy --apply --digest ${done.digest}${daysArg}`;
|
|
12948
|
+
return fail(write, json, command, done.reason, done.reason === "changed" ? `${done.message} Look again with \`toolroll task tidy --preview${daysArg}\`.` : done.message, EXIT.refused, { digest: done.digest, count: done.items.length, ...(done.reason === "changed" ? { next: done.items.length === 0 ? null : again } : {}) });
|
|
12949
|
+
}
|
|
12950
|
+
return succeed(write, json, command, { archived: done.archived.map(one => ({ id: one.taskId, rule: one.rule, reason: one.reason })), skipped: done.skipped }, () => [
|
|
12951
|
+
`Put away ${done.archived.length} task${done.archived.length === 1 ? "" : "s"}.`,
|
|
12952
|
+
...done.skipped.map(one => `Not put away: ${one.message}`),
|
|
12953
|
+
"Find them with `toolroll task list --view archived`; bring one back with `toolroll task restore <id>`.",
|
|
12954
|
+
]);
|
|
12955
|
+
}
|
|
12956
|
+
/** The project a put-away or restore proves the person's password against: the first named task's, when they can see
|
|
12957
|
+
* it; otherwise the first project they can see at all (null when none: an unrestricted person, or none to prove). */
|
|
12958
|
+
function firstVisibleRepo(store, name, ids) {
|
|
12959
|
+
for (const id of ids) {
|
|
12960
|
+
const repo = store.lookupRef(id)?.repo ?? null;
|
|
12961
|
+
if (repo !== null && store.accountCanAccess(name, repo))
|
|
12962
|
+
return repo;
|
|
12963
|
+
}
|
|
12964
|
+
return store.accountCanAccess(name, null) ? null : store.knownRepos().find(repo => store.accountCanAccess(name, repo)) ?? null;
|
|
12965
|
+
}
|
package/dist/project-delete.js
CHANGED
|
@@ -86,7 +86,9 @@ export function projectRunning(store, repo, now) {
|
|
|
86
86
|
const chats = count("SELECT COUNT(*) AS n FROM mate_turn WHERE state IN ('queued','running') AND id IN (SELECT value FROM json_each(?))", json(d.turns));
|
|
87
87
|
const steps = count("SELECT COUNT(*) AS n FROM flow_step_run WHERE state = 'running' AND card IN (SELECT value FROM json_each(?))", json(d.cards));
|
|
88
88
|
const calls = count("SELECT COUNT(*) AS n FROM subagent_call WHERE state = 'running' AND subagent IN (SELECT value FROM json_each(?))", json(d.subagents));
|
|
89
|
-
const merges = count(`SELECT COUNT(*) AS n FROM merge_intent WHERE state IN ('claimed','firing') AND publication IN (SELECT id FROM publication WHERE task_ref IN (SELECT value FROM json_each(?)))`, json(d.refs))
|
|
89
|
+
const merges = count(`SELECT COUNT(*) AS n FROM merge_intent WHERE state IN ('claimed','firing') AND publication IN (SELECT id FROM publication WHERE task_ref IN (SELECT value FROM json_each(?)))`, json(d.refs))
|
|
90
|
+
// A person's merge between its last check and GitHub's answer (v145); a row older than 15 minutes is a crash.
|
|
91
|
+
+ count("SELECT COUNT(*) AS n FROM merge_in_flight WHERE at > ? AND task_ref IN (SELECT value FROM json_each(?))", new Date(now.getTime() - 15 * 60_000).toISOString(), json(d.refs));
|
|
90
92
|
const checkouts = count("SELECT COUNT(*) AS n FROM worktree WHERE repo = ? AND runner IS NOT NULL AND released_at IS NULL", repo);
|
|
91
93
|
return [
|
|
92
94
|
...(building > 0 ? [`${plural(building, "task")} ${building === 1 ? "is" : "are"} being built`] : []),
|
|
@@ -267,6 +269,8 @@ function deleteRows(store, repo, d, now) {
|
|
|
267
269
|
del("skill_test", `${IN("task_ref")} OR ${IN("source_run")}`, T, R);
|
|
268
270
|
del("merge_blocker", `${IN("publication")} OR ${IN("task_id")}`, publications, K);
|
|
269
271
|
del("merge_intent", IN("publication"), publications);
|
|
272
|
+
del("merge_in_flight", IN("publication"), publications);
|
|
273
|
+
del("deploy_in_flight", IN("task_ref"), T);
|
|
270
274
|
del("publication", IN("id"), publications);
|
|
271
275
|
del("artifact", IN("id"), A);
|
|
272
276
|
// Tasks and their versions.
|
|
@@ -282,7 +286,7 @@ function deleteRows(store, repo, d, now) {
|
|
|
282
286
|
del("workflow_preview", "repo = ?", repo);
|
|
283
287
|
del("coding_handoff_scope", `id IN (SELECT id FROM coding_handoff WHERE json_extract(payload, '$.repo') = ?)`, repo);
|
|
284
288
|
del("coding_handoff", "json_extract(payload, '$.repo') = ?", repo);
|
|
285
|
-
for (const table of ["task_act", "task_replacement"])
|
|
289
|
+
for (const table of ["task_act", "task_replacement", "task_archive"])
|
|
286
290
|
del(table, IN("task_ref"), T);
|
|
287
291
|
del("task_ref", IN("id"), T);
|
|
288
292
|
del("task_scope", IN("task_id"), K);
|
|
@@ -293,7 +297,7 @@ function deleteRows(store, repo, d, now) {
|
|
|
293
297
|
del("flow_trigger", IN("flow"), F);
|
|
294
298
|
del("flow_unattended_run", `grant_id IN (SELECT id FROM flow_unattended_grant WHERE ${IN("flow")})`, F);
|
|
295
299
|
del("flow_unattended_grant", IN("flow"), F);
|
|
296
|
-
for (const table of ["flow_card_watcher", "flow_comment", "flow_event", "flow_mail", "flow_step_run"])
|
|
300
|
+
for (const table of ["flow_card_watcher", "flow_comment", "flow_event", "flow_mail", "flow_step_run", "flow_step_task"])
|
|
297
301
|
del(table, IN("card"), C);
|
|
298
302
|
del("subagent_question", IN("id"), questions);
|
|
299
303
|
del("subagent_call", `${IN("subagent")} OR ${IN("card")}`, M, C);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* read must show the PR open, on the accepted commit, with checks passing; GitHub's head-commit match guards
|
|
15
15
|
* the race; the branch is deleted; the merge commit and who merged are recorded in the ledger.
|
|
16
16
|
*/
|
|
17
|
-
import { createHash } from "node:crypto";
|
|
17
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
18
18
|
import { run as execRun } from "./exec.js";
|
|
19
19
|
import { bodyHashOf, publicationBody, publicationGrantOf } from "./publish.js";
|
|
20
20
|
import { failingChecks, summarizeChecks } from "./pulls.js";
|
|
@@ -23,7 +23,7 @@ import { requestResultChanges } from "./result-actions.js";
|
|
|
23
23
|
import { revisionSourceOf } from "./result-review.js";
|
|
24
24
|
import { authenticateApprover, personProof } from "./scope.js";
|
|
25
25
|
import { BRANCH_PREFIX, headWithin } from "./names.js";
|
|
26
|
-
import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
|
|
26
|
+
import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, refArchived, releaseTaskRef } from "./store.js";
|
|
27
27
|
import { belongsToRelease } from "./release-authority.js";
|
|
28
28
|
import { autoReleaseSealed } from "./release-auto.js";
|
|
29
29
|
import { fullCheckGate, requestFollowUpChecks } from "./result-follow-ups.js";
|
|
@@ -292,6 +292,9 @@ export async function followPullRequests(store, seen, options) {
|
|
|
292
292
|
continue;
|
|
293
293
|
const pr = publication.prNumber;
|
|
294
294
|
const taskId = taskIdOf(store, publication);
|
|
295
|
+
// A put-away task (v145) gets no automatic revision, merge or ready notice: restoring it resumes following.
|
|
296
|
+
if (refArchived(store, publication.taskRef))
|
|
297
|
+
continue;
|
|
295
298
|
if (one.remoteState === "MERGED") {
|
|
296
299
|
updateFollow(store, publication.id, { merge_commit: one.mergeCommit ?? "", merged_by: "GitHub", merged_at: clock().toISOString() }, clock());
|
|
297
300
|
continue;
|
|
@@ -397,6 +400,8 @@ export async function mergePullRequest(store, input) {
|
|
|
397
400
|
// The nightly check of main (release-once.md, part 3) is a check only: it never merges.
|
|
398
401
|
const mergedRun = store.getRun(input.runId);
|
|
399
402
|
const mergedTask = mergedRun === null ? null : store.refById(mergedRun.taskRef)?.externalId ?? null;
|
|
403
|
+
if (mergedRun !== null && refArchived(store, mergedRun.taskRef))
|
|
404
|
+
return refuse("archived", "This task is put away. Restore it before merging.");
|
|
400
405
|
if (mergedTask !== null && store.handle.prepare("SELECT 1 AS hit FROM nightly_check WHERE task = ?").get(mergedTask) !== undefined)
|
|
401
406
|
return refuse("check-only", "The nightly check of main is only a check; it never merges.");
|
|
402
407
|
// A release's task merges only through `toolroll release` (release-once.md), which proves the live release, reserves
|
|
@@ -470,8 +475,34 @@ export async function mergePullRequest(store, input) {
|
|
|
470
475
|
return fail("moved", "The pull request has commits that weren't part of the completed result, so it won't be merged from here.");
|
|
471
476
|
if (before.checks !== "passing")
|
|
472
477
|
return fail("checks", before.checks === "failing" ? "Checks are failing, so it can't merge." : before.checks === "running" ? "Checks are still running. Merge once they pass." : "No checks have reported yet. Merge once they pass.");
|
|
473
|
-
|
|
474
|
-
|
|
478
|
+
// The merge is reserved with the put-away check in one write (v145): a put away that committed first refuses it, and
|
|
479
|
+
// one that comes after waits for GitHub's answer (the reservation counts as work going on).
|
|
480
|
+
// One merge at a time, each owning its reservation by its token: a second caller is refused, and each releases only its own.
|
|
481
|
+
const token = randomUUID();
|
|
482
|
+
const reserved = store.transact(() => {
|
|
483
|
+
if (refArchived(store, publication.taskRef))
|
|
484
|
+
return "archived";
|
|
485
|
+
if (store.handle.prepare("SELECT 1 FROM hold WHERE task_ref = ? AND owner_id LIKE 'project-delete:%' AND (until IS NULL OR until > ?)").get(publication.taskRef, clock().toISOString()) !== undefined)
|
|
486
|
+
return "deleting";
|
|
487
|
+
if (store.handle.prepare("SELECT 1 FROM merge_in_flight WHERE publication = ? AND at > ?").get(publication.id, new Date(clock().getTime() - 15 * 60_000).toISOString()) !== undefined)
|
|
488
|
+
return "merging";
|
|
489
|
+
store.handle.prepare("INSERT OR REPLACE INTO merge_in_flight (publication, task_ref, token, by, at) VALUES (?, ?, ?, ?, ?)").run(publication.id, publication.taskRef, token, input.by, clock().toISOString());
|
|
490
|
+
return "ok";
|
|
491
|
+
});
|
|
492
|
+
if (reserved === "archived")
|
|
493
|
+
return refuse("archived", "This task is put away. Restore it before merging.");
|
|
494
|
+
if (reserved === "deleting")
|
|
495
|
+
return refuse("deleting", "This task's project is being deleted, so nothing merges.");
|
|
496
|
+
if (reserved === "merging")
|
|
497
|
+
return refuse("merging", "This pull request is already being merged. Wait for that to finish.");
|
|
498
|
+
let merged, after;
|
|
499
|
+
try {
|
|
500
|
+
merged = await exec("gh", ["pr", "merge", pr, "--repo", publication.githubRepo, `--${method}`, "--match-head-commit", publication.headSha, "--delete-branch"], { timeoutMs: EXEC_TIMEOUT_MS, env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" } });
|
|
501
|
+
after = await view();
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
store.handle.prepare("DELETE FROM merge_in_flight WHERE publication = ? AND token = ?").run(publication.id, token);
|
|
505
|
+
}
|
|
475
506
|
if (after?.state === "MERGED") {
|
|
476
507
|
record(after.commit, input.by);
|
|
477
508
|
return { ok: true, commit: after.commit };
|
|
@@ -2,6 +2,7 @@ import { checkoutRunner, ranAtLine, widestRan } from "./invoke.js";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { refArchived } from "./task-archive.js";
|
|
5
6
|
import { belongsToRelease } from "./release-authority.js";
|
|
6
7
|
import { run as execRun } from "./exec.js";
|
|
7
8
|
import { hardenedGit } from "./teardown-git.js";
|
|
@@ -126,6 +127,9 @@ function checkTarget(store, runId, level) {
|
|
|
126
127
|
return refuse("no-commit", "This result has no finished commit to check.");
|
|
127
128
|
if (ref.repo === null)
|
|
128
129
|
return refuse("unplaced", "This result isn't in a project, so there is no check to run.");
|
|
130
|
+
// A put-away task (v145) runs no checks: asked for or already waiting, it is refused until restored.
|
|
131
|
+
if (refArchived(store, ref.id))
|
|
132
|
+
return refuse("archived", "This task is put away. Restore it first.");
|
|
129
133
|
const chosen = checkCommandFor(store, ref.repo, level);
|
|
130
134
|
if (chosen.command === null)
|
|
131
135
|
return refuse("no-check", `${ref.repo} has no approved ${level === "quick" ? "quick or full" : "full"} check. Set one in Settings → Projects → Checks.`);
|
package/dist/scope.d.ts
CHANGED
|
@@ -597,6 +597,11 @@ export type ApproveResult = {
|
|
|
597
597
|
| {
|
|
598
598
|
ok: false;
|
|
599
599
|
reason: "machine-proof";
|
|
600
|
+
}
|
|
601
|
+
/** v145 (task-tidy.md): the task is put away; it is restored before anyone can approve it. */
|
|
602
|
+
| {
|
|
603
|
+
ok: false;
|
|
604
|
+
reason: "archived";
|
|
600
605
|
};
|
|
601
606
|
/**
|
|
602
607
|
* Whether this name-and-token pair is a person the store knows. Shared by
|
package/dist/scope.js
CHANGED
|
@@ -48,6 +48,7 @@ import { z } from "zod";
|
|
|
48
48
|
import {} from "./contracts/acceptance-terms.js";
|
|
49
49
|
import { parseContract } from "./contracts/contract.js";
|
|
50
50
|
import { readAcceptance, readScopeTerms, storedRubricSchema } from "./contracts/scope.js";
|
|
51
|
+
import { taskArchived } from "./task-archive.js";
|
|
51
52
|
import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
|
|
52
53
|
import { NO_READINESS, exactModelId, legOf, projectRoute, routeDigestOf, routeFromJson, routeJsonProblem, routeWords, } from "./phase-routing.js";
|
|
53
54
|
/** Same shape as a runner's credential, for the same reasons. */
|
|
@@ -1074,6 +1075,9 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1074
1075
|
const scope = store.getScope(taskId);
|
|
1075
1076
|
if (scope === null)
|
|
1076
1077
|
return { ok: false, reason: "no-scope" };
|
|
1078
|
+
// A put-away task (v145) is restored first; sealScopeApproval refuses it too, on every road.
|
|
1079
|
+
if (taskArchived(store, taskId))
|
|
1080
|
+
return { ok: false, reason: "archived" };
|
|
1077
1081
|
// The digest is required, not optional. An operator reads a scope, somebody
|
|
1078
1082
|
// rewrites it, and an approval that did not name what it saw would agree to
|
|
1079
1083
|
// the new one in silence — which is the whole failure this guards.
|
|
@@ -1472,6 +1476,9 @@ export function liveReleaseOf(store, taskId, action, now = new Date()) {
|
|
|
1472
1476
|
return { applies: false };
|
|
1473
1477
|
const { authority: a, carry } = found;
|
|
1474
1478
|
const refuse = (why) => ({ applies: true, ok: false, why, authority: a });
|
|
1479
|
+
// A put-away task (v145, task-tidy.md), or a put-away anchor, deploys, merges and carries nothing: restore it first.
|
|
1480
|
+
if (taskArchived(store, taskId) || taskArchived(store, a.anchorTask))
|
|
1481
|
+
return refuse("the release task is put away");
|
|
1475
1482
|
const newest = newestReleaseGeneration(store, a.repo, a.version, a.pr);
|
|
1476
1483
|
if (newest === null || newest.id !== a.id)
|
|
1477
1484
|
return refuse("a newer approval of this release replaced this one");
|
package/dist/server/chat.js
CHANGED
|
@@ -389,9 +389,11 @@ export function createChatHandlers(runtime) {
|
|
|
389
389
|
const id = proposalId, saved = store.getLeadProposal(id), savedAction = saved?.kind === 'action' ? sharedActionPayload(saved.payload) : null;
|
|
390
390
|
if (saved && savedAction && saved.state !== 'pending' && (sharedConversation !== null || store.getLeadThread(saved.thread)?.approver === principal.name) && principal.repos.includes(savedAction.repo) && store.accountCanAccess(principal.name, savedAction.repo)) {
|
|
391
391
|
const task = typeof saved.outcome?.['taskId'] === 'string' ? saved.outcome['taskId'] : typeof savedAction.request['task'] === 'string' ? savedAction.request['task'] : null;
|
|
392
|
-
|
|
392
|
+
// A flow action leads back to its flow (a page path of our own making, never the proposal's words).
|
|
393
|
+
const flowId = savedAction.operation.startsWith('flow_') && Number.isSafeInteger(savedAction.request['flow']) ? Number(savedAction.request['flow']) : null;
|
|
394
|
+
const destination = task ? taskHref(task) : flowId !== null ? `/flows/${flowId}` : `/settings/${savedAction.operation.startsWith('skill_') ? 'skills' : 'knowledge'}?repo=${encodeURIComponent(savedAction.repo)}`;
|
|
393
395
|
const said = typeof saved.outcome?.['said'] === 'string' ? saved.outcome['said'] : saved.state === 'dismissed' ? 'Action dismissed.' : saved.state === 'expired' ? 'This proposal expired. Ask for a fresh proposal.' : 'This action has no completed outcome yet.';
|
|
394
|
-
return sendScreen(response, 200, screen('Action outcome', html `<h1>${savedAction.title}</h1><p>${said}</p><a class="button-link" href="${destination}">${task ? 'Open task' : 'Open project settings'}</a>`, { chrome: chromeFor(savedAction.repo, 'chat') }));
|
|
396
|
+
return sendScreen(response, 200, screen('Action outcome', html `<h1>${savedAction.title}</h1><p>${said}</p><a class="button-link" href="${destination}">${task ? 'Open task' : flowId !== null ? 'Open flow' : 'Open project settings'}</a>`, { chrome: chromeFor(savedAction.repo, 'chat') }));
|
|
395
397
|
}
|
|
396
398
|
const review = mintSharedActionReview(store, principal, id, evidenceRoot, clock()), action = review.payload;
|
|
397
399
|
const terms = action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`);
|