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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type FlowDefinition } from "./flows.js";
|
|
2
|
+
import type { FlowRow, FlowScriptRow, Store } from "./store.js";
|
|
3
|
+
/** v144: each approval of browser use, live or ended. `digest` is browserApprovalDigest of what was approved. Additive only. */
|
|
4
|
+
export declare const FLOW_BROWSER_SCHEMA = "\nCREATE TABLE IF NOT EXISTS flow_browser_grant (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n flow INTEGER NOT NULL,\n repo TEXT NOT NULL,\n flow_revision INTEGER NOT NULL,\n flow_digest TEXT NOT NULL,\n digest TEXT NOT NULL,\n zones_json TEXT NOT NULL,\n signed_by TEXT NOT NULL,\n signed_at TEXT NOT NULL,\n proof TEXT NOT NULL,\n revoked_at TEXT,\n revoked_by TEXT\n);\nCREATE UNIQUE INDEX IF NOT EXISTS flow_browser_live ON flow_browser_grant (flow) WHERE revoked_at IS NULL;\n";
|
|
5
|
+
/** One zone an approval covers: the zone, and the script it runs (name, version and content digest). */
|
|
6
|
+
export type BrowserZone = {
|
|
7
|
+
zone: string;
|
|
8
|
+
script: string;
|
|
9
|
+
version: number;
|
|
10
|
+
digest: string;
|
|
11
|
+
};
|
|
12
|
+
/** Everything an approval is about: the flow, its project, the drawing (revision and digest) and every browser zone. */
|
|
13
|
+
export type BrowserTerms = {
|
|
14
|
+
flow: number;
|
|
15
|
+
repo: string;
|
|
16
|
+
revision: number;
|
|
17
|
+
flowDigest: string;
|
|
18
|
+
zones: BrowserZone[];
|
|
19
|
+
};
|
|
20
|
+
export type FlowBrowserGrant = BrowserTerms & {
|
|
21
|
+
id: number;
|
|
22
|
+
digest: string;
|
|
23
|
+
signedBy: string;
|
|
24
|
+
signedAt: string;
|
|
25
|
+
proof: string;
|
|
26
|
+
};
|
|
27
|
+
/** The approval's digest: domain-separated over the whole of its terms, so a changed flow, drawing, revision or any
|
|
28
|
+
* covered script (a restored one is a new version) is a different approval. */
|
|
29
|
+
export declare function browserApprovalDigest(terms: BrowserTerms): string;
|
|
30
|
+
/** The flow's script zones that say they start a browser, each with the script it runs now; null when one's script is
|
|
31
|
+
* missing, waiting for approval, or runs a project file (an approval seals the script's own text, never a file the
|
|
32
|
+
* project can change). */
|
|
33
|
+
export declare function browserZonesOf(store: Store, flow: FlowRow, definition: FlowDefinition): BrowserZone[] | null;
|
|
34
|
+
/** What an approval of this flow would be about now, read from the store; null when it can't be approved. */
|
|
35
|
+
export declare function browserTermsOf(store: Store, flow: FlowRow): BrowserTerms | null;
|
|
36
|
+
/** The flow's live approval, as recorded (whether it still stands is browserStanding's question). */
|
|
37
|
+
export declare function liveBrowserGrant(store: Store, flowId: number): FlowBrowserGrant | null;
|
|
38
|
+
/**
|
|
39
|
+
* Whether `zone` of this flow may start a browser now. `run`, when given, is what the caller is about to run — the
|
|
40
|
+
* drawing it read and the very script object it hands the launch — and the answer is about exactly that. The live
|
|
41
|
+
* approval stands only when its digest is the approval this flow would be now (same flow, project, drawing revision and
|
|
42
|
+
* digest, and every browser zone's script name, version and digest), it was a person's, and its signer is still an
|
|
43
|
+
* approver who reaches the project; `run`'s drawing must be that drawing and its script that zone's script. Anything
|
|
44
|
+
* unreadable, changed or mismatched is a no, with the plain words a card waits with.
|
|
45
|
+
*/
|
|
46
|
+
export declare function browserStanding(store: Store, flowId: number, zone: string, run?: {
|
|
47
|
+
definition: FlowDefinition;
|
|
48
|
+
script: FlowScriptRow;
|
|
49
|
+
}): {
|
|
50
|
+
allowed: true;
|
|
51
|
+
} | {
|
|
52
|
+
allowed: false;
|
|
53
|
+
said: string;
|
|
54
|
+
};
|
|
55
|
+
/** What approving means, in words the person reads before they give their password. */
|
|
56
|
+
export declare function browserTerms(zones: readonly BrowserZone[]): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Approve browser use for the flow as it stands. `proof` is the signer's credential method, proved by the caller just
|
|
59
|
+
* now; only a person's counts (a lead's or remote run's token is refused), from an approver who reaches the project.
|
|
60
|
+
* Re-reads the flow, its zones and scripts in the one transaction that writes; `sawDigest` (required) is the approval
|
|
61
|
+
* (browserApprovalDigest: drawing and scripts) the person read, and anything else is refused. Replaces any live one.
|
|
62
|
+
*/
|
|
63
|
+
export declare function signFlowBrowser(store: Store, input: {
|
|
64
|
+
flow: number;
|
|
65
|
+
signer: string;
|
|
66
|
+
proof: string | null;
|
|
67
|
+
sawDigest: string;
|
|
68
|
+
/** The console and chat: the signer's account must still be this generation (the session that proved it) and reach every project. */
|
|
69
|
+
console?: {
|
|
70
|
+
generation: number;
|
|
71
|
+
};
|
|
72
|
+
}, now: Date): {
|
|
73
|
+
ok: true;
|
|
74
|
+
grant: FlowBrowserGrant;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
message: string;
|
|
78
|
+
};
|
|
79
|
+
/** Any approver on the project ends it, with no password: lowering authority. False when there was none. */
|
|
80
|
+
export declare function revokeFlowBrowser(store: Store, flowId: number, by: string, now: Date): boolean;
|
|
81
|
+
/** What the flow page, `flows show` and chat say about browser use: null when no zone says it starts a browser. */
|
|
82
|
+
export type BrowserView = {
|
|
83
|
+
/** allowed: the approval stands now; ended: there is one, but it no longer stands; none: no one has approved. */
|
|
84
|
+
status: "allowed" | "ended" | "none";
|
|
85
|
+
/** The one line every surface shows. */
|
|
86
|
+
words: string;
|
|
87
|
+
signedBy: string | null;
|
|
88
|
+
signedAt: string | null;
|
|
89
|
+
/** The browser zones as they stand now, each with its title. Empty while one can't be approved (see `problem`). */
|
|
90
|
+
zones: (BrowserZone & {
|
|
91
|
+
title: string;
|
|
92
|
+
})[];
|
|
93
|
+
/** What approving means (browserTerms), and the approval digest the step-up must carry back. */
|
|
94
|
+
terms: string[];
|
|
95
|
+
digest: string | null;
|
|
96
|
+
/** Why it can't be approved as it stands, or null. */
|
|
97
|
+
problem: string | null;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Browser use for one flow, read from the store (no writes: page visits call it). Whether it is allowed is exactly
|
|
101
|
+
* browserStanding's answer for the flow's browser zones; the rest only says why in plain words.
|
|
102
|
+
*/
|
|
103
|
+
export declare function browserViewOf(store: Store, flow: FlowRow): BrowserView | null;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser use in a flow's script zone: a per-flow approval a person gives with their own proof, so that zone's launch
|
|
3
|
+
* gets the one sandbox rule a project check gets for Chromium (sandbox-runtime.ts SEATBELT_CHROMIUM) and nothing more.
|
|
4
|
+
*
|
|
5
|
+
* Two halves, both needed. The flow's drawing says which "Run a script" zones start a browser (`browser: true`, part of
|
|
6
|
+
* the flow's digest and said when the flow is confirmed); anyone who may change the flow can say it. Then a person, with
|
|
7
|
+
* their password or sign-in (never a lead's or remote run's token: person-proof.ts), approves browser use for the flow
|
|
8
|
+
* as it stands. The approval seals the flow's digest and, for each browser zone, its script's name and content digest.
|
|
9
|
+
* At launch (flow-steps.ts) the zone gets the rule only while all of that still holds: the same drawing, the same
|
|
10
|
+
* scripts, a person's proof, a signer who still reaches the project. Anything else gets nothing, and the card waits with
|
|
11
|
+
* a plain line naming the missing approval instead of Chromium crashing in the sandbox. Builds, checks, tool servers and
|
|
12
|
+
* every other flow launch (setup, triggers, zones that don't say browser) are untouched.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { canonicalJson } from "./canonical-json.js";
|
|
16
|
+
import { personProof } from "./person-proof.js";
|
|
17
|
+
import { flowDigest, validateFlowDefinition } from "./flows.js";
|
|
18
|
+
/** v144: each approval of browser use, live or ended. `digest` is browserApprovalDigest of what was approved. Additive only. */
|
|
19
|
+
export const FLOW_BROWSER_SCHEMA = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS flow_browser_grant (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
flow INTEGER NOT NULL,
|
|
23
|
+
repo TEXT NOT NULL,
|
|
24
|
+
flow_revision INTEGER NOT NULL,
|
|
25
|
+
flow_digest TEXT NOT NULL,
|
|
26
|
+
digest TEXT NOT NULL,
|
|
27
|
+
zones_json TEXT NOT NULL,
|
|
28
|
+
signed_by TEXT NOT NULL,
|
|
29
|
+
signed_at TEXT NOT NULL,
|
|
30
|
+
proof TEXT NOT NULL,
|
|
31
|
+
revoked_at TEXT,
|
|
32
|
+
revoked_by TEXT
|
|
33
|
+
);
|
|
34
|
+
CREATE UNIQUE INDEX IF NOT EXISTS flow_browser_live ON flow_browser_grant (flow) WHERE revoked_at IS NULL;
|
|
35
|
+
`;
|
|
36
|
+
function definitionOf(flow) {
|
|
37
|
+
try {
|
|
38
|
+
return validateFlowDefinition(JSON.parse(flow.definitionJson), { stored: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The approval's digest: domain-separated over the whole of its terms, so a changed flow, drawing, revision or any
|
|
45
|
+
* covered script (a restored one is a new version) is a different approval. */
|
|
46
|
+
export function browserApprovalDigest(terms) {
|
|
47
|
+
return createHash("sha256").update(`toolroll:flow-browser:${canonicalJson(terms)}`, "utf8").digest("hex").slice(0, 32);
|
|
48
|
+
}
|
|
49
|
+
/** The flow's script zones that say they start a browser, each with the script it runs now; null when one's script is
|
|
50
|
+
* missing, waiting for approval, or runs a project file (an approval seals the script's own text, never a file the
|
|
51
|
+
* project can change). */
|
|
52
|
+
export function browserZonesOf(store, flow, definition) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const stage of definition.stages) {
|
|
55
|
+
if (stage.kind !== "check" || stage.browser !== true)
|
|
56
|
+
continue;
|
|
57
|
+
const script = stage.script === null ? null : store.flowScript(flow.repo, stage.script);
|
|
58
|
+
if (script === null || script.held !== null || script.file !== null)
|
|
59
|
+
return null;
|
|
60
|
+
out.push({ zone: stage.id, script: script.name, version: script.version, digest: script.digest });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** What an approval of this flow would be about now, read from the store; null when it can't be approved. */
|
|
65
|
+
export function browserTermsOf(store, flow) {
|
|
66
|
+
const definition = definitionOf(flow);
|
|
67
|
+
const zones = definition === null ? null : browserZonesOf(store, flow, definition);
|
|
68
|
+
return definition === null || zones === null ? null : { flow: flow.id, repo: flow.repo, revision: flow.revision, flowDigest: flowDigest(definition), zones };
|
|
69
|
+
}
|
|
70
|
+
function readGrant(row) {
|
|
71
|
+
if (row === undefined)
|
|
72
|
+
return null;
|
|
73
|
+
let zones;
|
|
74
|
+
try {
|
|
75
|
+
zones = JSON.parse(String(row["zones_json"]));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(zones) || !zones.every(one => one !== null && typeof one === "object" && typeof one.zone === "string" && typeof one.script === "string" && Number.isInteger(one.version) && typeof one.digest === "string"))
|
|
81
|
+
return null;
|
|
82
|
+
return { id: Number(row["id"]), flow: Number(row["flow"]), repo: String(row["repo"]), revision: Number(row["flow_revision"]), flowDigest: String(row["flow_digest"]), digest: String(row["digest"]),
|
|
83
|
+
zones: zones, signedBy: String(row["signed_by"]), signedAt: String(row["signed_at"]), proof: String(row["proof"]) };
|
|
84
|
+
}
|
|
85
|
+
/** The flow's live approval, as recorded (whether it still stands is browserStanding's question). */
|
|
86
|
+
export function liveBrowserGrant(store, flowId) {
|
|
87
|
+
return readGrant(store.handle.prepare("SELECT * FROM flow_browser_grant WHERE flow = ? AND revoked_at IS NULL").get(flowId));
|
|
88
|
+
}
|
|
89
|
+
/** Whether `name` may approve on `repo` now: a live approver account that reaches the project. */
|
|
90
|
+
function approverNow(store, name, repo) {
|
|
91
|
+
const account = store.accountOf(name);
|
|
92
|
+
return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(name, repo);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether `zone` of this flow may start a browser now. `run`, when given, is what the caller is about to run — the
|
|
96
|
+
* drawing it read and the very script object it hands the launch — and the answer is about exactly that. The live
|
|
97
|
+
* approval stands only when its digest is the approval this flow would be now (same flow, project, drawing revision and
|
|
98
|
+
* digest, and every browser zone's script name, version and digest), it was a person's, and its signer is still an
|
|
99
|
+
* approver who reaches the project; `run`'s drawing must be that drawing and its script that zone's script. Anything
|
|
100
|
+
* unreadable, changed or mismatched is a no, with the plain words a card waits with.
|
|
101
|
+
*/
|
|
102
|
+
export function browserStanding(store, flowId, zone, run) {
|
|
103
|
+
const flow = store.getFlow(flowId);
|
|
104
|
+
const definition = flow === null ? null : definitionOf(flow);
|
|
105
|
+
const stage = definition?.stages.find(one => one.id === zone);
|
|
106
|
+
if (flow === null || definition === null || stage === undefined || stage.kind !== "check" || stage.browser !== true)
|
|
107
|
+
return { allowed: false, said: "This zone doesn't say it starts a browser." };
|
|
108
|
+
const approve = `Approve it with: toolroll flows browser ${flow.id} on`;
|
|
109
|
+
const grant = liveBrowserGrant(store, flow.id);
|
|
110
|
+
if (grant === null)
|
|
111
|
+
return { allowed: false, said: `${stage.title} starts a browser, and no one has approved browser use for the ${flow.name} flow, so it didn't run. ${approve}` };
|
|
112
|
+
const terms = browserTermsOf(store, flow);
|
|
113
|
+
const now = terms?.zones.find(one => one.zone === zone);
|
|
114
|
+
const runStage = run?.definition.stages.find(one => one.id === zone);
|
|
115
|
+
const runs = run === undefined || (terms !== null && now !== undefined && flowDigest(run.definition) === terms.flowDigest && runStage?.kind === "check" && runStage.browser === true
|
|
116
|
+
&& runStage.script === run.script.name && run.script.repo === flow.repo && run.script.held === null && run.script.file === null
|
|
117
|
+
&& run.script.name === now.script && run.script.version === now.version && run.script.digest === now.digest);
|
|
118
|
+
const stands = runs && terms !== null && now !== undefined && flow.state === "active" && grant.digest === browserApprovalDigest(terms)
|
|
119
|
+
&& browserApprovalDigest({ flow: grant.flow, repo: grant.repo, revision: grant.revision, flowDigest: grant.flowDigest, zones: grant.zones }) === grant.digest
|
|
120
|
+
&& personProof(grant.proof) && approverNow(store, grant.signedBy, flow.repo);
|
|
121
|
+
return stands ? { allowed: true } : { allowed: false, said: `${stage.title} starts a browser, but browser use was approved for an earlier version of the ${flow.name} flow or its scripts, so it didn't run. ${approve}` };
|
|
122
|
+
}
|
|
123
|
+
/** What approving means, in words the person reads before they give their password. */
|
|
124
|
+
export function browserTerms(zones) {
|
|
125
|
+
return [
|
|
126
|
+
`Lets ${zones.map(one => `the ${one.script} script (zone ${one.zone})`).join(", ")} start Playwright's Chromium headless shell inside the flow's sandbox: the same one macOS service a project check gets for it, and nothing else.`,
|
|
127
|
+
"Changing the flow or one of these scripts (even back to how it was) ends it; nothing else gets it.",
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Approve browser use for the flow as it stands. `proof` is the signer's credential method, proved by the caller just
|
|
132
|
+
* now; only a person's counts (a lead's or remote run's token is refused), from an approver who reaches the project.
|
|
133
|
+
* Re-reads the flow, its zones and scripts in the one transaction that writes; `sawDigest` (required) is the approval
|
|
134
|
+
* (browserApprovalDigest: drawing and scripts) the person read, and anything else is refused. Replaces any live one.
|
|
135
|
+
*/
|
|
136
|
+
export function signFlowBrowser(store, input, now) {
|
|
137
|
+
if (!personProof(input.proof))
|
|
138
|
+
return { ok: false, message: "Browser use needs a person's own approval: sign in with your password (a lead's or remote run's token can't give it)." };
|
|
139
|
+
return store.transact(() => {
|
|
140
|
+
const flow = store.getFlow(input.flow);
|
|
141
|
+
if (flow === null || flow.state !== "active")
|
|
142
|
+
return { ok: false, message: "No such flow." };
|
|
143
|
+
if (!approverNow(store, input.signer, flow.repo))
|
|
144
|
+
return { ok: false, message: "Only an approver on this flow's project can approve browser use." };
|
|
145
|
+
if (input.console !== undefined) {
|
|
146
|
+
const account = store.accountOf(input.signer);
|
|
147
|
+
if (account === null || account.generation !== input.console.generation)
|
|
148
|
+
return { ok: false, message: "Your access changed. Sign in again." };
|
|
149
|
+
if (account.projects !== null)
|
|
150
|
+
return { ok: false, message: "Allowing browser use needs an account with access to every project." };
|
|
151
|
+
}
|
|
152
|
+
if (definitionOf(flow) === null)
|
|
153
|
+
return { ok: false, message: "This flow's drawing can't be read." };
|
|
154
|
+
const terms = browserTermsOf(store, flow);
|
|
155
|
+
if (terms === null)
|
|
156
|
+
return { ok: false, message: "A zone that starts a browser has no script it can approve: missing, waiting for approval, or one that runs a project file (write the script on the zone instead)." };
|
|
157
|
+
if (terms.zones.length === 0)
|
|
158
|
+
return { ok: false, message: `No zone in ${flow.name} says it starts a browser. Mark the script zone first (browser: true), then approve.` };
|
|
159
|
+
const digest = browserApprovalDigest(terms);
|
|
160
|
+
if (input.sawDigest !== digest)
|
|
161
|
+
return { ok: false, message: "This flow or its scripts changed while you were reading. Read the terms again." };
|
|
162
|
+
store.handle.prepare("UPDATE flow_browser_grant SET revoked_at = ?, revoked_by = ? WHERE flow = ? AND revoked_at IS NULL").run(now.toISOString(), input.signer, flow.id);
|
|
163
|
+
const id = Number(store.handle.prepare("INSERT INTO flow_browser_grant (flow, repo, flow_revision, flow_digest, digest, zones_json, signed_by, signed_at, proof) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
164
|
+
.run(flow.id, flow.repo, terms.revision, terms.flowDigest, digest, JSON.stringify(terms.zones), input.signer, now.toISOString(), input.proof).lastInsertRowid);
|
|
165
|
+
store.recordAction({ at: now.toISOString(), actor: input.signer, repo: flow.repo, taskId: null, runId: null, source: "policy", action: "flow browser use: approved", outcome: "signed",
|
|
166
|
+
detail: `${flow.name} (flow #${flow.id}) · ${terms.zones.map(one => `${one.zone}: ${one.script} v${one.version}`).join(", ")}` });
|
|
167
|
+
return { ok: true, grant: { ...terms, id, digest, signedBy: input.signer, signedAt: now.toISOString(), proof: input.proof } };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Any approver on the project ends it, with no password: lowering authority. False when there was none. */
|
|
171
|
+
export function revokeFlowBrowser(store, flowId, by, now) {
|
|
172
|
+
return store.transact(() => {
|
|
173
|
+
const flow = store.getFlow(flowId);
|
|
174
|
+
const ended = store.handle.prepare("UPDATE flow_browser_grant SET revoked_at = ?, revoked_by = ? WHERE flow = ? AND revoked_at IS NULL").run(now.toISOString(), by, flowId).changes > 0;
|
|
175
|
+
if (ended)
|
|
176
|
+
store.recordAction({ at: now.toISOString(), actor: by, repo: flow?.repo ?? null, taskId: null, runId: null, source: "policy", action: "flow browser use: revoked", outcome: "revoked", detail: `flow #${flowId}` });
|
|
177
|
+
return ended;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const dayWords = (at) => new Date(at).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
181
|
+
/**
|
|
182
|
+
* Browser use for one flow, read from the store (no writes: page visits call it). Whether it is allowed is exactly
|
|
183
|
+
* browserStanding's answer for the flow's browser zones; the rest only says why in plain words.
|
|
184
|
+
*/
|
|
185
|
+
export function browserViewOf(store, flow) {
|
|
186
|
+
const definition = definitionOf(flow);
|
|
187
|
+
const stages = definition?.stages.filter(stage => stage.kind === "check" && stage.browser === true) ?? [];
|
|
188
|
+
if (definition === null || stages.length === 0)
|
|
189
|
+
return null;
|
|
190
|
+
const terms = browserTermsOf(store, flow);
|
|
191
|
+
const grant = liveBrowserGrant(store, flow.id);
|
|
192
|
+
const allowed = grant !== null && stages.every(stage => browserStanding(store, flow.id, stage.id).allowed);
|
|
193
|
+
const zones = (terms?.zones ?? []).map(zone => ({ ...zone, title: definition.stages.find(one => one.id === zone.zone)?.title ?? zone.zone }));
|
|
194
|
+
const problem = terms === null ? "A zone that starts a browser has no script that can be approved: it's missing, waiting for approval, or runs a project file (write the script on the zone instead)." : null;
|
|
195
|
+
const why = grant === null ? "" : terms === null || canonicalJson(grant.zones) !== canonicalJson(terms.zones) ? "the script changed"
|
|
196
|
+
: grant.flowDigest !== terms.flowDigest || grant.revision !== terms.revision ? "the flow changed"
|
|
197
|
+
: !approverNow(store, grant.signedBy, flow.repo) ? `${grant.signedBy} can no longer approve on this project` : "it no longer matches this flow";
|
|
198
|
+
return {
|
|
199
|
+
status: allowed ? "allowed" : grant === null ? "none" : "ended",
|
|
200
|
+
words: allowed ? `Browser: allowed by ${grant.signedBy}, ${dayWords(grant.signedAt)}` : grant === null ? "Browser: not allowed — runs will wait" : `Browser approval ended: ${why}`,
|
|
201
|
+
signedBy: grant?.signedBy ?? null, signedAt: grant?.signedAt ?? null,
|
|
202
|
+
zones, terms: terms === null ? [] : browserTerms(terms.zones), digest: terms === null ? null : browserApprovalDigest(terms), problem,
|
|
203
|
+
};
|
|
204
|
+
}
|
package/dist/flow-code.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { LANGUAGE_WORDS, SCRIPT_LANGUAGES, type ScriptLanguage } from "./flows.j
|
|
|
9
9
|
export declare const CODE_OUTPUT_CHARS: 12000;
|
|
10
10
|
export type CodeRun = {
|
|
11
11
|
script: FlowScriptRow;
|
|
12
|
+
/** Asked right before every spawn attempt (retries too); false refuses it. A browser zone's approval rides here. */
|
|
13
|
+
beforeSpawn?: () => boolean;
|
|
12
14
|
/** Where it runs, and the project folder a file script is found in. */
|
|
13
15
|
cwd: string;
|
|
14
16
|
root: string;
|
|
@@ -58,7 +60,8 @@ export declare function cardsFromOutput(stdout: string): {
|
|
|
58
60
|
* of its own for its input, an inline script and its temp files; writes as the project's writes setting allows (its
|
|
59
61
|
* folder, the copy's Git data, its project's caches); the scratch pool denied around its own folder; and its network
|
|
60
62
|
* open, as flows' scripts have always had it (they reach the services their secrets are for; no approval seals a
|
|
61
|
-
* network for them).
|
|
63
|
+
* network for them). `browser` (flow-browser.ts browserStanding, proved by the caller at launch) adds Chromium's one
|
|
64
|
+
* service, as a project check has it; nothing else asks for it. The fence is required: where this machine can't confine it, it runs only when an instance operator
|
|
62
65
|
* allowed unconfined agents, and otherwise nothing runs and the card says why.
|
|
63
66
|
*/
|
|
64
67
|
export type FlowLaunch = {
|
|
@@ -75,6 +78,7 @@ export declare function flowLaunch(store: Store, options: {
|
|
|
75
78
|
available?: () => boolean;
|
|
76
79
|
policy?: FencePolicy;
|
|
77
80
|
home?: string;
|
|
81
|
+
browser?: true;
|
|
78
82
|
}): FlowLaunch | null | {
|
|
79
83
|
problem: string;
|
|
80
84
|
};
|
package/dist/flow-code.js
CHANGED
|
@@ -79,6 +79,7 @@ export async function runCode(run) {
|
|
|
79
79
|
const ran = await run.shell("/bin/sh", ["-c", 'exec "$@" < "$FLOW_INPUT"', "flow-script", runtime.file, ...runtime.args], {
|
|
80
80
|
cwd: run.cwd, timeoutMs: script.timeoutMinutes * 60_000, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST, processGroup: true,
|
|
81
81
|
env: { ...run.env, ...run.secrets, ...(run.launch?.env ?? {}), FLOW_INPUT: inputFile }, ...(run.launch?.fence ?? {}),
|
|
82
|
+
...(run.beforeSpawn === undefined ? {} : { beforeSpawn: run.beforeSpawn }),
|
|
82
83
|
});
|
|
83
84
|
const printed = codeOutput(ran.stdout, run.secrets);
|
|
84
85
|
const all = scrubSecrets(ran.stdout.slice(0, 1_000_000), run.secrets);
|
|
@@ -192,7 +193,9 @@ export function flowLaunch(store, options) {
|
|
|
192
193
|
// The project's read denies apply to flows as to builds (read live: a flow seals no list, and these only narrow).
|
|
193
194
|
const readDeny = store.projectSandbox(options.repo).readDeny.map(one => readDenyPath(one, home));
|
|
194
195
|
const fence = fenceFor("flow", { databaseFile, worktree: options.folder, home, boundary, readDeny, policy: options.policy ?? 1, scratchRoots: [options.scratch], binding, runFolders: runFolders(own), poolRoots, extra: allowed?.deny ?? [],
|
|
195
|
-
writes: allowed === null ? null : { writes: allowed.writes, readOnly: allowed.readOnly }, network: { mode: "open" }
|
|
196
|
+
writes: allowed === null ? null : { writes: allowed.writes, readOnly: allowed.readOnly }, network: { mode: "open" },
|
|
197
|
+
// Chromium's one service, only for a script zone whose browser use a person approved, proved by the caller now.
|
|
198
|
+
...(options.browser === true ? { browser: true } : {}) });
|
|
196
199
|
return { folder: own, env: { ...env, ...(allowed === null ? {} : buildCacheEnv(cache)) }, fence, dispose };
|
|
197
200
|
}
|
|
198
201
|
catch {
|
package/dist/flow-engine.js
CHANGED
|
@@ -23,6 +23,7 @@ import { cardFollowers, notifyPeople } from "./flow-people.js";
|
|
|
23
23
|
import { DRAFT_CHARS, keptDraft } from "./flow-draft.js";
|
|
24
24
|
import { chooseStep, flowPersonOf, sendStep } from "./flow-send.js";
|
|
25
25
|
import { parseSoul, subagentLabel } from "./subagents.js";
|
|
26
|
+
import { cardTaskArchived, taskArchived } from "./task-archive.js";
|
|
26
27
|
import { withStageHandoff } from "./contracts/stage-output.js";
|
|
27
28
|
/** The flow's drawing, or null when it can't be read (the card then says so and waits). */
|
|
28
29
|
export function flowDefinitionOf(flow) {
|
|
@@ -90,6 +91,14 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
|
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
const stage = definition.stages.find(one => one.id === card.stage);
|
|
94
|
+
// A put-away task (v145, task-tidy.md) holds its card where it is, whatever the zone: nothing moves on, files, sends
|
|
95
|
+
// or notifies until the task is restored.
|
|
96
|
+
if (cardTaskArchived(store, card.id)) {
|
|
97
|
+
const waiting = "Its task is put away. Restore the task to continue.";
|
|
98
|
+
if (card.waiting !== waiting)
|
|
99
|
+
store.updateFlowCard(card.id, { waiting }, now);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
93
102
|
if (stage === undefined) {
|
|
94
103
|
// The zone was removed while the card sat in it: back to the start.
|
|
95
104
|
if (store.moveFlowCard(card.id, { to: definition.start, outcome: "moved", actor: "flow", note: null, expectEntry: card.entry }, now))
|
|
@@ -311,6 +320,13 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
|
|
|
311
320
|
store.updateFlowCard(card.id, { waiting: "Its task is gone. Move the card to try again." }, now);
|
|
312
321
|
return;
|
|
313
322
|
}
|
|
323
|
+
// A put-away task (v145, task-tidy.md) holds its card where it is: nothing moves on, files or sends until it's restored.
|
|
324
|
+
if (taskArchived(store, current) || taskArchived(store, card.task)) {
|
|
325
|
+
const waiting = "Its task is put away. Restore the task to continue.";
|
|
326
|
+
if (card.waiting !== waiting)
|
|
327
|
+
store.updateFlowCard(card.id, { waiting }, now);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
314
330
|
if (task.state === "done") {
|
|
315
331
|
// A build whose project checks failed is done but not good: it takes the failure path, like a failed build.
|
|
316
332
|
if (stage.kind === "task") {
|
package/dist/flow-live.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* can't keep one open falls back to reading every few seconds. With nothing
|
|
17
17
|
* written, an open flow costs no reads; a 30-second check heals a miss.
|
|
18
18
|
*/
|
|
19
|
+
import { browserViewOf } from "./flow-browser.js";
|
|
19
20
|
import { createHash } from "node:crypto";
|
|
20
21
|
import { createLiveRooms } from "./live-bus.js";
|
|
21
22
|
// Statements belong to their database, without keeping closed Stores alive.
|
|
@@ -90,7 +91,14 @@ export function flowFingerprint(store, flow, now = new Date()) {
|
|
|
90
91
|
// including a build in another project. The page read admits the viewer;
|
|
91
92
|
// this stream only carries a hash. No evidence files or logs are read.
|
|
92
93
|
const row = fingerprintStatement(store).get({ $flow: flow, $now: now.toISOString() });
|
|
93
|
-
|
|
94
|
+
if (row === undefined)
|
|
95
|
+
return null;
|
|
96
|
+
// Browser use (flow-browser.ts): an approval, a revocation, a changed covered script or a signer who lost standing
|
|
97
|
+
// changes what the page says, so it is part of what open pages are nudged on.
|
|
98
|
+
const saved = store.getFlow(flow);
|
|
99
|
+
const browser = saved === null ? null : browserViewOf(store, saved);
|
|
100
|
+
const browserKey = browser === null ? "" : `${browser.status}:${browser.digest ?? ""}:${browser.signedAt ?? ""}:${browser.problem === null ? "" : "x"}`;
|
|
101
|
+
return createHash("sha256").update([...Object.values(row).map(String), browserKey].join("|")).digest("hex").slice(0, 16);
|
|
94
102
|
}
|
|
95
103
|
/** Rooms keyed by flow. With a bus, a write reaches every open page at once; without one, only the safety net. */
|
|
96
104
|
export function createFlowRooms(fingerprint, options = {}) {
|
|
@@ -19,6 +19,7 @@ import { adoptPublishedPullRequest } from "./publish.js";
|
|
|
19
19
|
import { failingCheckOf, mergePullRequest, owePullRequest, pullRequestBlocker, pullRequestFollowOf } from "./pull-request-flow.js";
|
|
20
20
|
import { requestResultChanges } from "./result-actions.js";
|
|
21
21
|
import { revisionSourceOf } from "./result-review.js";
|
|
22
|
+
import { cardTaskArchived } from "./task-archive.js";
|
|
22
23
|
/** Whether a person approved this card at a "Person decides" zone since it was last built; their name when so.
|
|
23
24
|
* The MOST RECENT decision governs (approval policy §1.7): it must be a person's recorded decision, after the card's
|
|
24
25
|
* last build by the current definition AND after the exact build being merged finished (`builtAt`), so editing the
|
|
@@ -41,19 +42,23 @@ export function approvedSinceBuild(store, definition, card, builtAt = null) {
|
|
|
41
42
|
/** One pass of a Pull request zone for one card. Returns whether it changed anything. */
|
|
42
43
|
export async function pullRequestStep(store, flow, definition, stage, card, now, io) {
|
|
43
44
|
const titleOf = (id) => definition.stages.find(one => one.id === id)?.title ?? "another zone";
|
|
44
|
-
|
|
45
|
+
// Every write after GitHub answers re-proves, in its own transaction, that the card's work wasn't put away meanwhile
|
|
46
|
+
// (v145): a put away that committed first leaves the card where it is, saying nothing to anyone.
|
|
47
|
+
const unlessHeld = (write) => store.transact(() => cardTaskArchived(store, card.id) ? false : write());
|
|
48
|
+
const waiting = (said) => unlessHeld(() => {
|
|
45
49
|
if (card.waiting === said)
|
|
46
50
|
return false;
|
|
47
51
|
store.updateFlowCard(card.id, { waiting: said }, now);
|
|
48
52
|
return true;
|
|
49
|
-
};
|
|
50
|
-
const passed = (said, output) => {
|
|
53
|
+
});
|
|
54
|
+
const passed = (said, output) => unlessHeld(() => {
|
|
51
55
|
store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: output }, waiting: null }, now);
|
|
52
56
|
if (stage.next === null)
|
|
53
57
|
return waiting(`${said} Move the card on when you're ready.`);
|
|
54
58
|
return store.moveFlowCard(card.id, { to: stage.next, outcome: "ok", actor: "flow", historyNote: said, expectEntry: card.entry }, now);
|
|
55
|
-
};
|
|
56
|
-
const failed = (said, revision = null) =>
|
|
59
|
+
});
|
|
60
|
+
const failed = (said, revision = null) => unlessHeld(() => failedNow(said, revision));
|
|
61
|
+
const failedNow = (said, revision = null) => {
|
|
57
62
|
store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: said } }, now);
|
|
58
63
|
if (stage.onFail === null) {
|
|
59
64
|
if (!waiting(`${said} Fix it, then move the card to try again.`))
|
|
@@ -68,13 +73,13 @@ export async function pullRequestStep(store, flow, definition, stage, card, now,
|
|
|
68
73
|
};
|
|
69
74
|
// Publishing that can't proceed is not the build's fault: rebuilding wouldn't change it, so the card waits here,
|
|
70
75
|
// saying what to do, and the people on it hear once. It carries on by itself once the cause is fixed.
|
|
71
|
-
const stuck = (said) => {
|
|
76
|
+
const stuck = (said) => unlessHeld(() => {
|
|
72
77
|
if (!waiting(said))
|
|
73
78
|
return false;
|
|
74
79
|
const people = card.owner === null ? cardFollowers(store, card) : [card.owner];
|
|
75
80
|
notifyPeople(store, card, people, null, { key: `pr-stuck:${card.entry}`, subject: `${stage.title} is waiting on you for “${card.title}”`, attention: true, body: said }, now);
|
|
76
81
|
return true;
|
|
77
|
-
};
|
|
82
|
+
});
|
|
78
83
|
// With no failure path, a card that didn't pass waits for a person to move it (a move is a fresh visit).
|
|
79
84
|
if (stage.onFail === null && card.waiting !== null && card.waiting.endsWith(" Fix it, then move the card to try again."))
|
|
80
85
|
return false;
|
|
@@ -128,6 +133,9 @@ export async function pullRequestStep(store, flow, definition, stage, card, now,
|
|
|
128
133
|
// Back to a Build zone: a revision of the same work, carrying the failure (fenced as untrusted CI output).
|
|
129
134
|
let revision = null;
|
|
130
135
|
const target = definition.stages.find(one => one.id === stage.onFail);
|
|
136
|
+
// No revision of work put away while GitHub answered (v145).
|
|
137
|
+
if (cardTaskArchived(store, card.id))
|
|
138
|
+
return false;
|
|
131
139
|
if (target?.kind === "task" && io.evidenceRoot !== undefined) {
|
|
132
140
|
const runTask = store.externalIdFor(store.getRun(runId)?.taskRef ?? -1);
|
|
133
141
|
const revised = runTask === null ? null : requestResultChanges(store, io.evidenceRoot, {
|
package/dist/flow-share.js
CHANGED
|
@@ -54,6 +54,8 @@ function stepOf(stage, ask) {
|
|
|
54
54
|
step["runIn"] = stage.runIn;
|
|
55
55
|
if (stage.secrets !== undefined)
|
|
56
56
|
step["secrets"] = stage.secrets;
|
|
57
|
+
if (stage.browser === true)
|
|
58
|
+
step["browser"] = true;
|
|
57
59
|
}
|
|
58
60
|
if (stage.routes !== undefined)
|
|
59
61
|
step["routes"] = stage.routes.map(one => ({ answer: one.answer, goesTo: one.to }));
|
|
@@ -299,7 +301,7 @@ function stageInputOf(step, index, find) {
|
|
|
299
301
|
...(toOwner ? { toOwner: true } : { approver: anyone ? null : decider }),
|
|
300
302
|
message: step["message"], close: step["close"], script: step["script"], runIn: step["runIn"],
|
|
301
303
|
routes: list("routes")?.map((one, n) => ({ answer: one["answer"], to: find(one["goesTo"], `${at}.routes[${n}].goesTo`) })),
|
|
302
|
-
secrets: step["secrets"],
|
|
304
|
+
secrets: step["secrets"], browser: step["browser"],
|
|
303
305
|
...(kind === "sort" ? { sort: { question: step["question"], answers: (list("answers") ?? []).map((one, n) => ({ answer: one["answer"], means: one["means"], to: find(one["goesTo"], `${at}.answers[${n}].goesTo`) })),
|
|
304
306
|
sureAt: typeof sureAt === "number" && sureAt > 1 ? sureAt / 100 : sureAt, notes: list("alsoNote") ?? [] } } : {}),
|
|
305
307
|
...(kind === "request" ? { request: { method: step["method"], url: step["url"], headers: object("headers") ?? {}, body: step["body"] } } : {}),
|
package/dist/flow-steps.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Runner } from "./backend.js";
|
|
2
2
|
import { type DraftRunner } from "./flow-draft.js";
|
|
3
3
|
import { type MailSender, type ToolCaller } from "./flow-actions.js";
|
|
4
|
-
import type { Store } from "./store.js";
|
|
4
|
+
import type { FlowCardRow, FlowStepKind, Store } from "./store.js";
|
|
5
5
|
import type { TurnRunner } from "./subagents.js";
|
|
6
6
|
import type { ToolLister } from "./subagent-tools.js";
|
|
7
7
|
export type StepIo = {
|
|
@@ -42,3 +42,11 @@ export type StepPass = {
|
|
|
42
42
|
};
|
|
43
43
|
/** Run every due check and update in one project, then move each card on. */
|
|
44
44
|
export declare function runFlowSteps(store: Store, repo: string, now: Date, io: StepIo): Promise<StepPass>;
|
|
45
|
+
/** Claim this card's visit to its step zone, only while the card is still there on that visit: a pass that read the card
|
|
46
|
+
* before another pass (or a person) moved it never runs the step for a visit that's over. */
|
|
47
|
+
export declare function claimStep(store: Store, card: FlowCardRow, step: {
|
|
48
|
+
stage: string;
|
|
49
|
+
kind: FlowStepKind;
|
|
50
|
+
script: string | null;
|
|
51
|
+
scriptVersion: number | null;
|
|
52
|
+
}, now: Date): boolean;
|