toolroll 0.9.102 → 0.9.103
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 +5 -0
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +8 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/flow-engine.d.ts +3 -0
- package/dist/flow-engine.js +4 -1
- package/dist/flows-ui.d.ts +31 -1
- package/dist/flows-ui.js +40 -12
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/release-approval.d.ts +9 -20
- package/dist/release-approval.js +14 -53
- package/dist/release-refusal.d.ts +32 -0
- package/dist/release-refusal.js +73 -0
- package/dist/scope.js +6 -0
- package/dist/server/home-asks.d.ts +45 -0
- package/dist/server/home-asks.js +108 -0
- package/dist/server/home.d.ts +15 -5
- package/dist/server/home.js +53 -18
- package/dist/server/pages.js +15 -3
- package/dist/work-index.d.ts +10 -5
- package/dist/work-index.js +23 -13
- package/package.json +1 -1
|
@@ -1725,8 +1725,9 @@ export type BrowserHomeView = {
|
|
|
1725
1725
|
value: number;
|
|
1726
1726
|
href: string;
|
|
1727
1727
|
}[];
|
|
1728
|
-
/** What needs you, by what is asked, in this order: approve, answer, wrong, review, other
|
|
1729
|
-
* fits). `count` is the exact total for that ask, past the
|
|
1728
|
+
/** What needs you, by what is asked, in this order: release, approve, answer, flow, wrong, review, setup, other
|
|
1729
|
+
* (anything none of the others fits; docs/plans/home-other-asks.md). `count` is the exact total for that ask, past the
|
|
1730
|
+
* rows listed. */
|
|
1730
1731
|
groups: {
|
|
1731
1732
|
key: BrowserHomeAsk;
|
|
1732
1733
|
label: string;
|
|
@@ -1761,9 +1762,10 @@ export type BrowserHomeView = {
|
|
|
1761
1762
|
/** While the first run is open (no first result yet): the welcome's address, linked as Finish setup. */
|
|
1762
1763
|
setup?: string | null;
|
|
1763
1764
|
};
|
|
1764
|
-
/** What a waiting row asks (work-index.ts WorkAskGroup). */
|
|
1765
|
-
export type BrowserHomeAsk = 'approve' | 'answer' | 'wrong' | 'review' | 'other';
|
|
1765
|
+
/** What a waiting row asks (work-index.ts WorkAskGroup), plus a flow card waiting on you and a first-run step. */
|
|
1766
|
+
export type BrowserHomeAsk = 'release' | 'approve' | 'answer' | 'flow' | 'wrong' | 'review' | 'setup' | 'other';
|
|
1766
1767
|
export type BrowserHomeRow = {
|
|
1768
|
+
/** A task row's family id; a row that isn't a task is namespaced (`release:<task>`, `flow:<card>`, `setup:<step>`). */
|
|
1767
1769
|
id: string;
|
|
1768
1770
|
title: string;
|
|
1769
1771
|
href: string;
|
|
@@ -1777,7 +1779,8 @@ export type BrowserHomeRow = {
|
|
|
1777
1779
|
reason: string | null;
|
|
1778
1780
|
/** One line: what it asks, or what the agent is doing now. */
|
|
1779
1781
|
line: string | null;
|
|
1780
|
-
/** The one act that resolves it (a link), or null.
|
|
1782
|
+
/** The one act that resolves it (a link), or null. A release, a flow card or a setup step is never acted on at
|
|
1783
|
+
* Home: its one link opens its own page. */
|
|
1781
1784
|
action: {
|
|
1782
1785
|
label: string;
|
|
1783
1786
|
href: string;
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -97,13 +97,13 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
|
|
|
97
97
|
export declare function lockHeld(file: string): boolean;
|
|
98
98
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
99
99
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
100
|
-
error?: never;
|
|
101
100
|
active: boolean;
|
|
102
101
|
phase: string;
|
|
103
102
|
detail: string;
|
|
104
103
|
running: boolean;
|
|
105
104
|
canResume: boolean;
|
|
106
105
|
canCancel: boolean;
|
|
106
|
+
error?: never;
|
|
107
107
|
automaticRecovery?: never;
|
|
108
108
|
recoveryAttempts?: never;
|
|
109
109
|
recoveryRepairs?: never;
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export type FlowAdvance = {
|
|
|
9
9
|
export declare function flowDefinitionOf(flow: FlowRow): FlowDefinition | null;
|
|
10
10
|
/** Where a card's link opens: the flow's canvas with the card selected. */
|
|
11
11
|
export declare const flowCardHref: (flow: number, card: number) => string;
|
|
12
|
+
/** A reply wait that timed out with nowhere to send the card asks its owner (or the flow's owner) to move it on: the
|
|
13
|
+
* card's waiting words end with this (flows-ui.ts flowCardWaitsOn reads it). */
|
|
14
|
+
export declare const NO_REPLY_MOVE_ON = "Move the card on when you're ready.";
|
|
12
15
|
/** Where a confirmed flow action may point: a flow's canvas, maybe with one card open. */
|
|
13
16
|
export declare const FLOW_HREF: RegExp;
|
|
14
17
|
/** Advance every active card in one project's flows. */
|
package/dist/flow-engine.js
CHANGED
|
@@ -36,6 +36,9 @@ export function flowDefinitionOf(flow) {
|
|
|
36
36
|
}
|
|
37
37
|
/** Where a card's link opens: the flow's canvas with the card selected. */
|
|
38
38
|
export const flowCardHref = (flow, card) => `/flows/${flow}?card=${card}`;
|
|
39
|
+
/** A reply wait that timed out with nowhere to send the card asks its owner (or the flow's owner) to move it on: the
|
|
40
|
+
* card's waiting words end with this (flows-ui.ts flowCardWaitsOn reads it). */
|
|
41
|
+
export const NO_REPLY_MOVE_ON = "Move the card on when you're ready.";
|
|
39
42
|
/** Where a confirmed flow action may point: a flow's canvas, maybe with one card open. */
|
|
40
43
|
export const FLOW_HREF = /^\/flows\/[1-9][0-9]{0,9}(\?card=[1-9][0-9]{0,9})?$/;
|
|
41
44
|
const ACCEPTANCE = FLOW_ACCEPTANCE;
|
|
@@ -173,7 +176,7 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
|
|
|
173
176
|
outcome.moved++;
|
|
174
177
|
return;
|
|
175
178
|
}
|
|
176
|
-
const waiting = `No reply after ${words}.
|
|
179
|
+
const waiting = `No reply after ${words}. ${NO_REPLY_MOVE_ON}`;
|
|
177
180
|
if (card.waiting !== waiting) {
|
|
178
181
|
store.updateFlowCard(card.id, { waiting }, now);
|
|
179
182
|
notifyPeople(store, card, [card.owner ?? flow.owner], null, { key: `no-reply:${card.entry}`, subject: `No reply to “${card.title}” after ${words}`, body: `It's waiting in ${stage.title}.`, attention: true }, now);
|
package/dist/flows-ui.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BrowserFlowView } from "./browser-workspace.js";
|
|
2
|
-
import
|
|
2
|
+
import { type FlowStage } from "./flows.js";
|
|
3
|
+
import type { FlowCardRow, FlowRow, Store, SubagentQuestionRow } from "./store.js";
|
|
3
4
|
import { type FlowFile, type FlowImportPlan } from "./flow-share.js";
|
|
4
5
|
import { type Html } from "./html.js";
|
|
5
6
|
/** The flow page's fallback rules, plus the Flows list and the import preview (kit pieces; only layout here). */
|
|
@@ -24,6 +25,35 @@ export declare function flowImportHtml(input: {
|
|
|
24
25
|
previewed: string;
|
|
25
26
|
problem: string | null;
|
|
26
27
|
}): Html;
|
|
28
|
+
/** Why a card waits on this person now (docs/plans/home-other-asks.md part 4, v2.3): the one rule the flow page's
|
|
29
|
+
* Decide and Home's Flow decisions both read, so they never disagree. Each needs approver standing:
|
|
30
|
+
* - `decide`: an approval zone it is theirs to decide (the zone's decider, the flow's owner for "to owner", any approver
|
|
31
|
+
* when none is named), and no subagent of the zone deciding it first;
|
|
32
|
+
* - `choose`: a "Person chooses" zone waiting on them (flow-send.ts flowChoiceAt);
|
|
33
|
+
* - `answer`: its subagent's open question or tool-call request asked of them;
|
|
34
|
+
* - `browser`: a check zone waiting for browser use, which only an approver with every project may allow;
|
|
35
|
+
* - `reply`: a reply wait that ran out with nowhere to go, asking the card's owner (else the flow's) to move it on.
|
|
36
|
+
* `reads` passes what the caller already read (a missing key is read here). */
|
|
37
|
+
export type FlowCardWait = {
|
|
38
|
+
decide: boolean;
|
|
39
|
+
choose: boolean;
|
|
40
|
+
answer: boolean;
|
|
41
|
+
browser: boolean;
|
|
42
|
+
reply: boolean;
|
|
43
|
+
};
|
|
44
|
+
export declare function flowCardWaitsOn(store: Store, flow: FlowRow, card: FlowCardRow, viewer: {
|
|
45
|
+
name: string;
|
|
46
|
+
approver: boolean;
|
|
47
|
+
}, reads?: {
|
|
48
|
+
stage?: FlowStage | undefined;
|
|
49
|
+
asked?: SubagentQuestionRow | null;
|
|
50
|
+
choosing?: {
|
|
51
|
+
person: string;
|
|
52
|
+
} | null;
|
|
53
|
+
browser?: {
|
|
54
|
+
status: string;
|
|
55
|
+
} | null;
|
|
56
|
+
}): FlowCardWait;
|
|
27
57
|
/** One flow's canvas for one person. */
|
|
28
58
|
export declare function flowView(store: Store, flow: FlowRow, viewer: {
|
|
29
59
|
name: string;
|
package/dist/flows-ui.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Flows in the console: the list of a person's flows, and one flow's canvas
|
|
2
2
|
* (the React view's data, plus a plain fallback page the view replaces). */
|
|
3
3
|
import { liveAttemptOf, steerControlsFor } from "./steer-controls.js";
|
|
4
|
-
import { draftFor, flowDefinitionOf } from "./flow-engine.js";
|
|
4
|
+
import { draftFor, flowDefinitionOf, NO_REPLY_MOVE_ON } from "./flow-engine.js";
|
|
5
5
|
import { describeStandingOrder } from "./flow-schedule.js";
|
|
6
6
|
import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, planeStatusLink, readHooksBase, readLinearKey, senderOf, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
|
|
7
7
|
import { SENDER_WORDS } from "./webhook-signing.js";
|
|
@@ -14,7 +14,7 @@ import { parseSortDecision, sortChip } from "./flow-sort.js";
|
|
|
14
14
|
import { flowSecretNames, readEmailSettings, sendingReady } from "./flow-actions.js";
|
|
15
15
|
import { projectToolsOf, secretsSetFor, toolStanding } from "./project-tools.js";
|
|
16
16
|
import { flowFingerprint } from "./flow-live.js";
|
|
17
|
-
import { flowChoiceAt, readFlowSend } from "./flow-send.js";
|
|
17
|
+
import { flowChoiceAt, flowPersonOf, readFlowSend } from "./flow-send.js";
|
|
18
18
|
import { itemCaption, whyLines } from "./flow-items.js";
|
|
19
19
|
import { FLOW_FILE_MAX_BYTES } from "./flow-share.js";
|
|
20
20
|
import { googleConnected } from "./google-mail.js";
|
|
@@ -224,6 +224,37 @@ const historyText = (event, title, sorts = new Set()) => {
|
|
|
224
224
|
default: return `Moved to ${title(event.toStage)}${who}${note}`;
|
|
225
225
|
}
|
|
226
226
|
};
|
|
227
|
+
export function flowCardWaitsOn(store, flow, card, viewer, reads = {}) {
|
|
228
|
+
const none = { decide: false, choose: false, answer: false, browser: false, reply: false };
|
|
229
|
+
if (!viewer.approver || card.state !== "active")
|
|
230
|
+
return none;
|
|
231
|
+
const stage = "stage" in reads ? reads.stage : flowDefinitionOf(flow)?.stages.find(one => one.id === card.stage);
|
|
232
|
+
// Its subagent's open question is the person's to answer whatever zone it is in, even one since removed from the flow
|
|
233
|
+
// (the flow page shows it and takes the answer).
|
|
234
|
+
const asked = "asked" in reads ? reads.asked ?? null : store.openSubagentQuestionOn(card.id, card.entry);
|
|
235
|
+
const answer = asked !== null && asked.state === "open" && asked.askedOf === viewer.name && store.getSubagent(asked.subagent) !== null;
|
|
236
|
+
if (stage === undefined)
|
|
237
|
+
return { ...none, answer };
|
|
238
|
+
const decider = stage.kind === "approval" ? deciderOf(stage, flow) : null;
|
|
239
|
+
// While its subagent is deciding (v92), the card isn't anyone else's to decide yet.
|
|
240
|
+
const subagentDeciding = stage.subagent !== undefined && card.waiting !== null && card.waiting.endsWith(" is deciding");
|
|
241
|
+
const choosing = stage.kind !== "choose" ? null : "choosing" in reads ? reads.choosing ?? null : flowChoiceAt(store, card.id, card.entry);
|
|
242
|
+
const browser = stage.kind !== "check" || stage.browser !== true ? null : "browser" in reads ? reads.browser ?? null : browserViewOf(store, flow);
|
|
243
|
+
const wait = stage.wait ?? { for: "reply" };
|
|
244
|
+
return {
|
|
245
|
+
decide: stage.kind === "approval" && (decider === null || decider === viewer.name) && !subagentDeciding,
|
|
246
|
+
choose: choosing !== null && choosing.person === viewer.name,
|
|
247
|
+
answer,
|
|
248
|
+
browser: browserWaitOn(card, stage, browser) && store.accountOf(viewer.name)?.projects === null,
|
|
249
|
+
reply: stage.kind === "wait" && wait.for === "reply" && card.waiting !== null && card.waiting.startsWith("No reply after ") && card.waiting.endsWith(NO_REPLY_MOVE_ON)
|
|
250
|
+
&& flowPersonOf(card, flow) === viewer.name,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** A check zone's card waiting for browser use (flow-browser.ts): the flow's browser isn't allowed and the card says so. */
|
|
254
|
+
function browserWaitOn(card, stage, browser) {
|
|
255
|
+
return card.state === "active" && stage?.kind === "check" && stage.browser === true && browser !== null && browser.status !== "allowed"
|
|
256
|
+
&& card.waiting !== null && card.waiting.startsWith(`${stage.title} starts a browser`);
|
|
257
|
+
}
|
|
227
258
|
/** One flow's canvas for one person. */
|
|
228
259
|
export function flowView(store, flow, viewer, selectedCard, setup = { dir: null, repos: [] }) {
|
|
229
260
|
const definition = flowDefinitionOf(flow);
|
|
@@ -249,10 +280,12 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
|
|
|
249
280
|
const canReadTask = taskRepo !== null && (taskRepo === flow.repo || (setup.repos.includes(taskRepo) && store.accountCanAccess(viewer.name, taskRepo)));
|
|
250
281
|
const discussion = store.flowComments(card.id);
|
|
251
282
|
const watchers = store.flowCardWatchers(card.id);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
283
|
+
// Its open question: one of its own, or (v94) a tool call waiting for approval.
|
|
284
|
+
const asked = card.state === "active" ? store.openSubagentQuestionOn(card.id, card.entry) : null;
|
|
285
|
+
// "Person chooses" (flow-send.ts): what was sent, and the options, for the person it waits on; the latest send otherwise.
|
|
286
|
+
const choosing = card.state === "active" && stage?.kind === "choose" ? flowChoiceAt(store, card.id, card.entry) : null;
|
|
287
|
+
// Who may decide it here: the one rule Home reads too (flowCardWaitsOn).
|
|
288
|
+
const canDecide = flowCardWaitsOn(store, flow, card, viewer, { stage, asked, choosing, browser: browserNow }).decide;
|
|
256
289
|
// History reads moves and ownership together, newest first.
|
|
257
290
|
const owned = discussion.filter(one => one.kind === "owner").map(one => ({ text: one.body === "" ? `${one.author} left it without an owner` : one.body === one.author ? `${one.author} took it on` : `${one.author} made ${one.body} the owner`, at: one.at }));
|
|
258
291
|
const moves = store.flowEvents(card.id).map(event => ({ text: historyText(event, title, sortZones), at: event.at }));
|
|
@@ -266,8 +299,6 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
|
|
|
266
299
|
at: until.toISOString(),
|
|
267
300
|
label: stage.wait !== undefined && stage.wait.for !== "hours" ? (stage.wait.for === "reply" ? "No reply by" : "Moves on at") : stage.limit.to !== null ? `Moves to ${title(stage.limit.to)} at` : "Reminder at",
|
|
268
301
|
};
|
|
269
|
-
// Its open question: one of its own, or (v94) a tool call waiting for approval.
|
|
270
|
-
const asked = card.state === "active" ? store.openSubagentQuestionOn(card.id, card.entry) : null;
|
|
271
302
|
const pending = asked?.toolCall == null ? null : store.subagentCall(asked.toolCall);
|
|
272
303
|
// A decision its subagent handed over carries what the subagent said (v92).
|
|
273
304
|
const turn = card.state === "active" && stage?.kind === "approval" && stage.subagent !== undefined ? store.flowStepRun(card.id, card.entry) : null;
|
|
@@ -284,8 +315,6 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
|
|
|
284
315
|
}
|
|
285
316
|
}
|
|
286
317
|
const asker = asked === null || asked.state !== "open" ? null : store.getSubagent(asked.subagent);
|
|
287
|
-
// "Person chooses" (flow-send.ts): what was sent, and the options, for the person it waits on; the latest send otherwise.
|
|
288
|
-
const choosing = card.state === "active" && stage?.kind === "choose" ? flowChoiceAt(store, card.id, card.entry) : null;
|
|
289
318
|
const latest = store.flowSends(card.id)[0] ?? null;
|
|
290
319
|
const sentContent = latest === null ? null : readFlowSend(latest.contentJson);
|
|
291
320
|
// On the card itself: where it came from (the card's title is right above), and only the links the panel doesn't already have.
|
|
@@ -295,8 +324,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
|
|
|
295
324
|
// After research: the same numbered items the message carried, each beside its screenshot.
|
|
296
325
|
...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
|
|
297
326
|
image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
|
|
298
|
-
const browserWait = card
|
|
299
|
-
&& card.waiting !== null && card.waiting.startsWith(`${stage.title} starts a browser`);
|
|
327
|
+
const browserWait = browserWaitOn(card, stage, browser);
|
|
300
328
|
return {
|
|
301
329
|
id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state,
|
|
302
330
|
waiting: card.state === "active" && card.task !== null && (stage?.kind === "task" || stage?.kind === "report")
|
|
@@ -24,10 +24,10 @@ export declare function knowledgeVersion(store: Store, repo: string, actor: stri
|
|
|
24
24
|
export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
|
|
25
25
|
title?: never;
|
|
26
26
|
content?: never;
|
|
27
|
-
history?: never;
|
|
28
27
|
revision: number;
|
|
29
28
|
stale: boolean;
|
|
30
29
|
notice: string;
|
|
30
|
+
history?: never;
|
|
31
31
|
instructions?: never;
|
|
32
32
|
references?: never;
|
|
33
33
|
decisions?: never;
|
|
@@ -35,7 +35,6 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
35
35
|
} | {
|
|
36
36
|
title?: never;
|
|
37
37
|
content?: never;
|
|
38
|
-
history?: never;
|
|
39
38
|
stale?: never;
|
|
40
39
|
decision: {
|
|
41
40
|
repo: string;
|
|
@@ -54,17 +53,18 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
54
53
|
afterwards?: string;
|
|
55
54
|
notice: string;
|
|
56
55
|
revision?: never;
|
|
56
|
+
history?: never;
|
|
57
57
|
instructions?: never;
|
|
58
58
|
references?: never;
|
|
59
59
|
decisions?: never;
|
|
60
60
|
waiting?: never;
|
|
61
61
|
} | {
|
|
62
|
-
history?: never;
|
|
63
62
|
stale?: never;
|
|
64
63
|
revision: number;
|
|
65
64
|
title: string;
|
|
66
65
|
content: string;
|
|
67
66
|
notice: string;
|
|
67
|
+
history?: never;
|
|
68
68
|
instructions?: never;
|
|
69
69
|
references?: never;
|
|
70
70
|
decisions?: never;
|
|
@@ -1,22 +1,8 @@
|
|
|
1
1
|
import { type PullRequestReader, type ReleaseCard, type ReleaseSeal, type TermLine } from "./release-card.js";
|
|
2
2
|
import { type ApproveResult, type Scope } from "./scope.js";
|
|
3
3
|
import { type Notification, type Store } from "./store.js";
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
/** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
|
|
7
|
-
export type ReleaseCiRun = {
|
|
8
|
-
id: number | null;
|
|
9
|
-
name: string;
|
|
10
|
-
status: string;
|
|
11
|
-
conclusion: string | null;
|
|
12
|
-
};
|
|
13
|
-
export type ReleaseCiSnapshot = {
|
|
14
|
-
candidate: string;
|
|
15
|
-
at: string;
|
|
16
|
-
state: "passed" | "running" | "failed";
|
|
17
|
-
failed: string[];
|
|
18
|
-
runs: ReleaseCiRun[];
|
|
19
|
-
};
|
|
4
|
+
import { type ReleaseCiSnapshot } from "./release-refusal.js";
|
|
5
|
+
export { ciWords, releaseApprovalRefusal, releaseCiOf, RELEASE_CI_SEEN_ACTION, type ReleaseCiRun, type ReleaseCiSnapshot } from "./release-refusal.js";
|
|
20
6
|
/**
|
|
21
7
|
* Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
|
|
22
8
|
* attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
|
|
@@ -35,13 +21,9 @@ export declare function recordReleaseCi(store: Store, input: {
|
|
|
35
21
|
}[];
|
|
36
22
|
now: Date;
|
|
37
23
|
}): boolean;
|
|
38
|
-
/** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
|
|
39
|
-
export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
|
|
40
24
|
/** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
|
|
41
25
|
* in progress read alike (neither is finished); the time it was read is not bound. */
|
|
42
26
|
export declare function ciBinding(ci: ReleaseCiSnapshot | null): unknown;
|
|
43
|
-
/** GitHub CI in one line, as the sheet and the CLI say it. */
|
|
44
|
-
export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
|
|
45
27
|
export type ReleaseApprovalCard = {
|
|
46
28
|
taskId: string;
|
|
47
29
|
taskRef: number;
|
|
@@ -189,3 +171,10 @@ export declare function waitingJournals(stateDir: string, expectedFile: (branch:
|
|
|
189
171
|
journals: WaitingJournal[];
|
|
190
172
|
unreadable: string[];
|
|
191
173
|
};
|
|
174
|
+
/** A release as Home names it (docs/plans/home-other-asks.md, part 1): its version, and why it can't take a yes now
|
|
175
|
+
* in the sheet's own words (`refusalOf`, the one source), or null when this isn't a release. Reads only; Home never
|
|
176
|
+
* approves one, it links to the sheet. */
|
|
177
|
+
export declare function releaseAskOf(store: Store, taskId: string): {
|
|
178
|
+
version: string;
|
|
179
|
+
refusal: string | null;
|
|
180
|
+
} | null;
|
package/dist/release-approval.js
CHANGED
|
@@ -19,20 +19,19 @@ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { permissionPlainWords } from "./chat-terms.js";
|
|
21
21
|
import { approvalTermsOf, releaseAccessWords, releaseCardOf, releaseSealOf } from "./release-card.js";
|
|
22
|
-
import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
|
|
23
22
|
import { approvalOf } from "./scope.js";
|
|
24
23
|
import { releaseProgressFor } from "./store.js";
|
|
25
24
|
import { taskArchived } from "./task-archive.js";
|
|
26
25
|
import { cleanTerminalLine } from "./terminal-text.js";
|
|
26
|
+
import { ciName, ciWords, refusalOf, releaseCiOf, RELEASE_CI_SEEN_ACTION } from "./release-refusal.js";
|
|
27
|
+
// The CI reading and the refusals live in release-refusal.ts, so scope.ts approve() can read them without a cycle.
|
|
28
|
+
export { ciWords, releaseApprovalRefusal, releaseCiOf, RELEASE_CI_SEEN_ACTION } from "./release-refusal.js";
|
|
27
29
|
const SHA = /^[0-9a-f]{40}$/;
|
|
28
30
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
29
31
|
/** A scope digest as scope.ts writes it. */
|
|
30
32
|
const SCOPE_DIGEST = /^[0-9a-f]{32,64}$/;
|
|
31
33
|
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
32
34
|
// ---- GitHub CI as the coordinator read it -------------------------------------------------------------------------
|
|
33
|
-
/** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
|
|
34
|
-
export const RELEASE_CI_SEEN_ACTION = "release CI observed";
|
|
35
|
-
const ciName = (value) => cleanTerminalLine(String(value ?? "")).trim().slice(0, 120);
|
|
36
35
|
/**
|
|
37
36
|
* Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
|
|
38
37
|
* attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
|
|
@@ -58,60 +57,11 @@ export function recordReleaseCi(store, input) {
|
|
|
58
57
|
action: RELEASE_CI_SEEN_ACTION, outcome: input.state, source: "work", detail });
|
|
59
58
|
return true;
|
|
60
59
|
}
|
|
61
|
-
/** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
|
|
62
|
-
export function releaseCiOf(store, taskId, candidate) {
|
|
63
|
-
const row = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE task_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(taskId, RELEASE_CI_SEEN_ACTION);
|
|
64
|
-
if (row === undefined)
|
|
65
|
-
return null;
|
|
66
|
-
let parsed;
|
|
67
|
-
try {
|
|
68
|
-
parsed = JSON.parse(String(row["detail"]));
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
const one = parsed;
|
|
74
|
-
if (one.v !== 1 || one.candidate !== candidate || !["passed", "running", "failed"].includes(String(one.state)) || !Array.isArray(one.failed) || !Array.isArray(one.runs))
|
|
75
|
-
return null;
|
|
76
|
-
return { candidate, at: String(row["at"]), state: one.state, failed: one.failed.map(ciName),
|
|
77
|
-
runs: one.runs.map(run => { const r = run; return { id: typeof r["id"] === "number" ? r["id"] : null, name: ciName(r["name"]), status: ciName(r["status"]), conclusion: r["conclusion"] == null ? null : ciName(r["conclusion"]) }; }) };
|
|
78
|
-
}
|
|
79
60
|
/** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
|
|
80
61
|
* in progress read alike (neither is finished); the time it was read is not bound. */
|
|
81
62
|
export function ciBinding(ci) {
|
|
82
63
|
return ci === null ? null : { candidate: ci.candidate, state: ci.state, failed: ci.failed, runs: ci.runs.map(run => [run.id, run.name, run.status === "completed", run.conclusion]) };
|
|
83
64
|
}
|
|
84
|
-
/** GitHub CI in one line, as the sheet and the CLI say it. */
|
|
85
|
-
export function ciWords(ci, candidate) {
|
|
86
|
-
const short = candidate.slice(0, 7);
|
|
87
|
-
if (ci === null)
|
|
88
|
-
return `GitHub CI on ${short} hasn't been read yet.`;
|
|
89
|
-
if (ci.state === "passed")
|
|
90
|
-
return `GitHub CI passed on ${short}.`;
|
|
91
|
-
if (ci.state === "failed")
|
|
92
|
-
return `GitHub CI failed on ${short}: ${ci.failed.join(", ") || "a check"}.`;
|
|
93
|
-
const finished = ci.runs.filter(run => run.status === "completed").length;
|
|
94
|
-
return `GitHub CI is running on ${short}: ${finished} of ${ci.runs.length} checks finished.`;
|
|
95
|
-
}
|
|
96
|
-
/** The release check failed on this task already (the coordinator's record): nobody approves it again here. */
|
|
97
|
-
function checkFailed(store, taskId) {
|
|
98
|
-
return store.handle.prepare("SELECT 1 FROM release_step WHERE task_id = ? AND step = 'check' AND state = 'failed' LIMIT 1").get(taskId) !== undefined;
|
|
99
|
-
}
|
|
100
|
-
/** Why a release can't be approved now, from the plane's own state, or null. */
|
|
101
|
-
function refusalOf(store, taskId, scope, ci) {
|
|
102
|
-
if (taskArchived(store, taskId))
|
|
103
|
-
return "This release is put away. Restore it before approving.";
|
|
104
|
-
const hold = releaseCiHoldOf(store, taskId);
|
|
105
|
-
if (hold !== null)
|
|
106
|
-
return releaseCiHoldWords(hold);
|
|
107
|
-
if (ci?.state === "failed")
|
|
108
|
-
return `${ciWords(ci, scope.candidate)} Fix the branch or rerun the failed job on GitHub, then release again.`;
|
|
109
|
-
if (checkFailed(store, taskId))
|
|
110
|
-
return "The release check already failed on this commit. Fix the branch and release the new commit.";
|
|
111
|
-
if (scope.approvedAt !== null && scope.approvedDigest !== scope.digest)
|
|
112
|
-
return "The release's terms changed after an earlier approval. Read them again.";
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
65
|
/** A release task from the plane's own state: a sealed form (terms or a release check's purpose) whose repository is the
|
|
116
66
|
* task's project and whose approved commit is the scope's candidate. Never from a title or a task id. */
|
|
117
67
|
export function releaseTaskOf(store, taskId) {
|
|
@@ -380,3 +330,14 @@ export function waitingJournals(stateDir, expectedFile) {
|
|
|
380
330
|
}
|
|
381
331
|
return { journals, unreadable };
|
|
382
332
|
}
|
|
333
|
+
/** A release as Home names it (docs/plans/home-other-asks.md, part 1): its version, and why it can't take a yes now
|
|
334
|
+
* in the sheet's own words (`refusalOf`, the one source), or null when this isn't a release. Reads only; Home never
|
|
335
|
+
* approves one, it links to the sheet. */
|
|
336
|
+
export function releaseAskOf(store, taskId) {
|
|
337
|
+
const found = releaseTaskOf(store, taskId);
|
|
338
|
+
if (found === null)
|
|
339
|
+
return null;
|
|
340
|
+
const { scope, seal } = found;
|
|
341
|
+
const refusal = approvalOf(scope).approved ? null : refusalOf(store, taskId, scope, releaseCiOf(store, taskId, scope.candidate));
|
|
342
|
+
return { version: seal.identity.packageVersion, refusal };
|
|
343
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Scope } from "./scope.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
/** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
|
|
4
|
+
export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
|
|
5
|
+
/** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
|
|
6
|
+
export type ReleaseCiRun = {
|
|
7
|
+
id: number | null;
|
|
8
|
+
name: string;
|
|
9
|
+
status: string;
|
|
10
|
+
conclusion: string | null;
|
|
11
|
+
};
|
|
12
|
+
export type ReleaseCiSnapshot = {
|
|
13
|
+
candidate: string;
|
|
14
|
+
at: string;
|
|
15
|
+
state: "passed" | "running" | "failed";
|
|
16
|
+
failed: string[];
|
|
17
|
+
runs: ReleaseCiRun[];
|
|
18
|
+
};
|
|
19
|
+
export declare const ciName: (value: unknown) => string;
|
|
20
|
+
/** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
|
|
21
|
+
export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
|
|
22
|
+
/** GitHub CI in one line, as the sheet and the CLI say it. */
|
|
23
|
+
export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
|
|
24
|
+
/** Why a release can't be approved now, from the plane's own state, or null. */
|
|
25
|
+
export declare function refusalOf(store: Store, taskId: string, scope: Scope, ci: ReleaseCiSnapshot | null): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The release refusals every approval road shares (scope.ts approve(), read inside its transaction): for a scope
|
|
28
|
+
* sealing release terms or a release check's purpose, the sheet's own reasons in the sheet's own words, so the task
|
|
29
|
+
* page, Home's in-place yes, chat and `toolroll task approve` refuse exactly what `/t/<id>/release` refuses. Null for
|
|
30
|
+
* every other scope, and for a release nothing stops. The sheet's card digest binding stays the sheet's.
|
|
31
|
+
*/
|
|
32
|
+
export declare function releaseApprovalRefusal(store: Store, taskId: string, scope: Scope): string | null;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a release can't be approved now, from the plane's own state (release-approval.ts re-exports all of it): the
|
|
3
|
+
* coordinator's GitHub CI reading on the exact candidate, and the one list of refusals the release sheet shows and every
|
|
4
|
+
* approval road applies (scope.ts approve() reads `releaseApprovalRefusal` inside its transaction). Kept apart from
|
|
5
|
+
* release-approval.ts so scope.ts reads it without importing the card machinery (an import cycle through store.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
|
|
8
|
+
import { taskArchived } from "./task-archive.js";
|
|
9
|
+
import { cleanTerminalLine } from "./terminal-text.js";
|
|
10
|
+
/** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
|
|
11
|
+
export const RELEASE_CI_SEEN_ACTION = "release CI observed";
|
|
12
|
+
export const ciName = (value) => cleanTerminalLine(String(value ?? "")).trim().slice(0, 120);
|
|
13
|
+
/** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
|
|
14
|
+
export function releaseCiOf(store, taskId, candidate) {
|
|
15
|
+
const row = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE task_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(taskId, RELEASE_CI_SEEN_ACTION);
|
|
16
|
+
if (row === undefined)
|
|
17
|
+
return null;
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(String(row["detail"]));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const one = parsed;
|
|
26
|
+
if (one.v !== 1 || one.candidate !== candidate || !["passed", "running", "failed"].includes(String(one.state)) || !Array.isArray(one.failed) || !Array.isArray(one.runs))
|
|
27
|
+
return null;
|
|
28
|
+
return { candidate, at: String(row["at"]), state: one.state, failed: one.failed.map(ciName),
|
|
29
|
+
runs: one.runs.map(run => { const r = run; return { id: typeof r["id"] === "number" ? r["id"] : null, name: ciName(r["name"]), status: ciName(r["status"]), conclusion: r["conclusion"] == null ? null : ciName(r["conclusion"]) }; }) };
|
|
30
|
+
}
|
|
31
|
+
/** GitHub CI in one line, as the sheet and the CLI say it. */
|
|
32
|
+
export function ciWords(ci, candidate) {
|
|
33
|
+
const short = candidate.slice(0, 7);
|
|
34
|
+
if (ci === null)
|
|
35
|
+
return `GitHub CI on ${short} hasn't been read yet.`;
|
|
36
|
+
if (ci.state === "passed")
|
|
37
|
+
return `GitHub CI passed on ${short}.`;
|
|
38
|
+
if (ci.state === "failed")
|
|
39
|
+
return `GitHub CI failed on ${short}: ${ci.failed.join(", ") || "a check"}.`;
|
|
40
|
+
const finished = ci.runs.filter(run => run.status === "completed").length;
|
|
41
|
+
return `GitHub CI is running on ${short}: ${finished} of ${ci.runs.length} checks finished.`;
|
|
42
|
+
}
|
|
43
|
+
/** The release check failed on this task already (the coordinator's record): nobody approves it again here. */
|
|
44
|
+
function checkFailed(store, taskId) {
|
|
45
|
+
return store.handle.prepare("SELECT 1 FROM release_step WHERE task_id = ? AND step = 'check' AND state = 'failed' LIMIT 1").get(taskId) !== undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Why a release can't be approved now, from the plane's own state, or null. */
|
|
48
|
+
export function refusalOf(store, taskId, scope, ci) {
|
|
49
|
+
if (taskArchived(store, taskId))
|
|
50
|
+
return "This release is put away. Restore it before approving.";
|
|
51
|
+
const hold = releaseCiHoldOf(store, taskId);
|
|
52
|
+
if (hold !== null)
|
|
53
|
+
return releaseCiHoldWords(hold);
|
|
54
|
+
if (ci?.state === "failed")
|
|
55
|
+
return `${ciWords(ci, scope.candidate)} Fix the branch or rerun the failed job on GitHub, then release again.`;
|
|
56
|
+
if (checkFailed(store, taskId))
|
|
57
|
+
return "The release check already failed on this commit. Fix the branch and release the new commit.";
|
|
58
|
+
if (scope.approvedAt !== null && scope.approvedDigest !== scope.digest)
|
|
59
|
+
return "The release's terms changed after an earlier approval. Read them again.";
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The release refusals every approval road shares (scope.ts approve(), read inside its transaction): for a scope
|
|
64
|
+
* sealing release terms or a release check's purpose, the sheet's own reasons in the sheet's own words, so the task
|
|
65
|
+
* page, Home's in-place yes, chat and `toolroll task approve` refuse exactly what `/t/<id>/release` refuses. Null for
|
|
66
|
+
* every other scope, and for a release nothing stops. The sheet's card digest binding stays the sheet's.
|
|
67
|
+
*/
|
|
68
|
+
export function releaseApprovalRefusal(store, taskId, scope) {
|
|
69
|
+
// A sealed form, as release-card.ts releaseSealOf reads it (release terms, or a release check's purpose).
|
|
70
|
+
if (scope.releaseTerms == null && scope.releaseCheck == null)
|
|
71
|
+
return null;
|
|
72
|
+
return refusalOf(store, taskId, scope, scope.candidate == null ? null : releaseCiOf(store, taskId, scope.candidate));
|
|
73
|
+
}
|
package/dist/scope.js
CHANGED
|
@@ -54,6 +54,7 @@ import { parseContract } from "./contracts/contract.js";
|
|
|
54
54
|
import { readAcceptance, readScopeTerms, storedRubricSchema } from "./contracts/scope.js";
|
|
55
55
|
import { taskArchived, taskCancelled } from "./task-archive.js";
|
|
56
56
|
import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
|
|
57
|
+
import { releaseApprovalRefusal } from "./release-refusal.js";
|
|
57
58
|
import { NO_READINESS, exactModelId, legOf, projectRoute, routeDigestOf, routeFromJson, routeJsonProblem, routeWords, } from "./phase-routing.js";
|
|
58
59
|
/** Same shape as a runner's credential, for the same reasons. */
|
|
59
60
|
function mintToken() {
|
|
@@ -1117,6 +1118,11 @@ chat) {
|
|
|
1117
1118
|
const scope = store.getScope(taskId);
|
|
1118
1119
|
if (scope === null)
|
|
1119
1120
|
return { ok: false, reason: "no-scope" };
|
|
1121
|
+
// A release takes no yes the release sheet would refuse (put away, CI held or failed, its check failed on this
|
|
1122
|
+
// commit, terms changed after an earlier yes), in the sheet's words, on every road: read here, before any vote.
|
|
1123
|
+
const releaseRefusal = releaseApprovalRefusal(store, taskId, scope);
|
|
1124
|
+
if (releaseRefusal !== null)
|
|
1125
|
+
return { ok: false, reason: "policy", message: releaseRefusal };
|
|
1120
1126
|
// A put-away task (v145) is restored first; sealScopeApproval refuses it too, on every road.
|
|
1121
1127
|
if (taskArchived(store, taskId))
|
|
1122
1128
|
return { ok: false, reason: "archived" };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FirstRunStep } from "../first-run.js";
|
|
2
|
+
import type { Store } from "../store.js";
|
|
3
|
+
import type { WorkSummaryAccess } from "../work-summary.js";
|
|
4
|
+
/** One row Home lists that isn't a task's own row. `id` is namespaced so it never meets a task's id; `at` (when it
|
|
5
|
+
* started waiting) gives its age, or null for a setup step; `repo` its project. */
|
|
6
|
+
export type HomeAskRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
href: string;
|
|
10
|
+
repo: string | null;
|
|
11
|
+
at: string | null;
|
|
12
|
+
line: string | null;
|
|
13
|
+
action: {
|
|
14
|
+
label: string;
|
|
15
|
+
href: string;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export type HomeAsks = {
|
|
19
|
+
/** A release's line for an approver (its version, or why the sheet can't take a yes now); null when it isn't one. */
|
|
20
|
+
release: (taskId: string) => string | null;
|
|
21
|
+
/** Releases whose publication waits for a person, one per family, only where that family isn't already a row. */
|
|
22
|
+
held: HomeAskRow[];
|
|
23
|
+
/** Flow cards waiting on this person. */
|
|
24
|
+
flows: HomeAskRow[];
|
|
25
|
+
/** First-run steps still to do, keyed by step. */
|
|
26
|
+
setup: (HomeAskRow & {
|
|
27
|
+
key: FirstRunStep["key"];
|
|
28
|
+
})[];
|
|
29
|
+
};
|
|
30
|
+
export declare const NO_HOME_ASKS: HomeAsks;
|
|
31
|
+
/** The release sheet: where a release is read and approved, never Home. */
|
|
32
|
+
export declare const releaseSheetHref: (taskId: string) => string;
|
|
33
|
+
/** A release's line (its version, or the sheet's refusal) and every held publication in view. Approvers only: the sheet
|
|
34
|
+
* mints its approval only for them; anyone else reads a release as an ordinary task row. */
|
|
35
|
+
export declare function releaseAsks(store: Store, now: Date, access: WorkSummaryAccess, project: string | null, approver: boolean): Pick<HomeAsks, "release" | "held">;
|
|
36
|
+
/** Every active card, in a flow of a project this person may see, that waits on them (flows-ui.ts flowCardWaitsOn, the
|
|
37
|
+
* flow page's own rule). Newest wait first. */
|
|
38
|
+
export declare function flowAsks(store: Store, repos: readonly string[], viewer: {
|
|
39
|
+
name: string;
|
|
40
|
+
approver: boolean;
|
|
41
|
+
}, project: string | null): HomeAskRow[];
|
|
42
|
+
/** The first-run steps (first-run.ts, with the builder step) still to do, for an approver. A step that is done, or only
|
|
43
|
+
* being checked (no answer yet, so no action), is not a row. Null steps (a restricted console, the demo, after the first
|
|
44
|
+
* success) are none. */
|
|
45
|
+
export declare function setupAsks(steps: readonly FirstRunStep[] | null, approver: boolean): HomeAsks["setup"];
|