toolroll 0.9.102 → 0.9.104
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 +13 -0
- package/README.md +10 -8
- package/dist/assignment.d.ts +32 -0
- package/dist/assignment.js +140 -4
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-workspace.d.ts +21 -5
- package/dist/contracts/console-api.d.ts +3 -2
- package/dist/contracts/console-api.js +4 -2
- package/dist/desktop-update.d.ts +2 -2
- 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/keymap.d.ts +3 -1
- package/dist/keymap.js +3 -0
- package/dist/local-owner-acts.d.ts +83 -0
- package/dist/local-owner-acts.js +221 -0
- package/dist/local-owner.d.ts +12 -1
- package/dist/local-owner.js +26 -0
- package/dist/operate-remote-arguments.js +4 -2
- package/dist/operate.js +76 -1
- package/dist/plan-auto.js +5 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/public-access.d.ts +9 -0
- package/dist/public-access.js +15 -0
- package/dist/pull-request-flow.js +9 -0
- package/dist/release-approval.d.ts +9 -20
- package/dist/release-approval.js +14 -53
- package/dist/release-card.d.ts +15 -1
- package/dist/release-card.js +35 -12
- package/dist/release-refusal.d.ts +32 -0
- package/dist/release-refusal.js +73 -0
- package/dist/result-completion.d.ts +6 -2
- package/dist/result-completion.js +13 -7
- package/dist/scope.d.ts +32 -1
- package/dist/scope.js +116 -25
- package/dist/serve.js +13 -14
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +11 -1
- 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/server/remote.d.ts +1 -0
- package/dist/server/render-results.d.ts +4 -0
- package/dist/server/render-tasks.d.ts +9 -0
- package/dist/server/render-tasks.js +4 -4
- package/dist/server/route-table.js +4 -0
- package/dist/server/runtime.d.ts +1 -0
- package/dist/server/task-views.js +3 -0
- package/dist/server/tasks.d.ts +25 -0
- package/dist/server/tasks.js +46 -6
- package/dist/surface.js +12 -2
- package/dist/task-outcome-cli.d.ts +18 -0
- package/dist/task-outcome-cli.js +60 -2
- package/dist/tui-contract.d.ts +61 -3
- package/dist/tui-source.js +91 -20
- package/dist/tui.js +54 -54
- package/dist/work-index.d.ts +10 -5
- package/dist/work-index.js +23 -13
- package/package.json +2 -2
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
|
+
}
|
package/dist/release-card.d.ts
CHANGED
|
@@ -82,10 +82,24 @@ export declare function releaseBaselineRecord(store: Store, repo: string): {
|
|
|
82
82
|
recorded: true;
|
|
83
83
|
sha: string | null;
|
|
84
84
|
};
|
|
85
|
+
/** How long the change list's settings check may take before the list is unread. */
|
|
86
|
+
export declare const CONFIG_LIST_MS = 60000;
|
|
85
87
|
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
86
88
|
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
87
89
|
* a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
|
|
88
|
-
* history).
|
|
90
|
+
* history). When they can't be read, why, in Toolroll's own fixed words (never the repository's text), so a card says
|
|
91
|
+
* why it shows no list rather than a shortened one. */
|
|
92
|
+
export declare function readCommitsBetween(repo: string, from: string, to: string): {
|
|
93
|
+
ok: true;
|
|
94
|
+
commits: {
|
|
95
|
+
sha: string;
|
|
96
|
+
subject: string;
|
|
97
|
+
}[];
|
|
98
|
+
} | {
|
|
99
|
+
ok: false;
|
|
100
|
+
why: string;
|
|
101
|
+
};
|
|
102
|
+
/** readCommitsBetween's list, or null when it can't be read. */
|
|
89
103
|
export declare function commitsBetween(repo: string, from: string, to: string): {
|
|
90
104
|
sha: string;
|
|
91
105
|
subject: string;
|
package/dist/release-card.js
CHANGED
|
@@ -207,32 +207,52 @@ export function releaseBaselineRecord(store, repo) {
|
|
|
207
207
|
const ref = row["external_ref"];
|
|
208
208
|
return { recorded: true, sha: typeof ref === "string" && SHA.test(ref) ? ref : null };
|
|
209
209
|
}
|
|
210
|
+
/** How long the change list's settings check may take before the list is unread. */
|
|
211
|
+
export const CONFIG_LIST_MS = 60_000;
|
|
210
212
|
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
211
213
|
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
212
214
|
* a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
|
|
213
|
-
* history).
|
|
214
|
-
|
|
215
|
+
* history). When they can't be read, why, in Toolroll's own fixed words (never the repository's text), so a card says
|
|
216
|
+
* why it shows no list rather than a shortened one. */
|
|
217
|
+
export function readCommitsBetween(repo, from, to) {
|
|
215
218
|
if (!SHA.test(from) || !SHA.test(to))
|
|
216
|
-
return
|
|
219
|
+
return { ok: false, why: "a commit id isn't a full id" };
|
|
217
220
|
let gitDir;
|
|
218
221
|
try {
|
|
219
222
|
const named = join(realpathSync(repo), ".git");
|
|
220
223
|
if (!lstatSync(named).isDirectory())
|
|
221
|
-
return
|
|
224
|
+
return { ok: false, why: "the project has no Git folder of its own" };
|
|
222
225
|
gitDir = realpathSync(named);
|
|
223
226
|
}
|
|
224
227
|
catch {
|
|
225
|
-
return
|
|
228
|
+
return { ok: false, why: "the project's Git folder can't be read" };
|
|
226
229
|
}
|
|
227
230
|
const tools = releaseTools();
|
|
228
231
|
const env = { ...releaseGitEnv(), PATH: RELEASE_PATH };
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
// The list's first Git call took over 15 s on GitHub's busy macOS runners (2026-11-12, "timed out after 15 s"; the
|
|
233
|
+
// same call takes 0.1 s on an idle Mac), and the card lost its change list; the verified range's own Git calls allow
|
|
234
|
+
// far longer. One minute, then the list is unread, as before.
|
|
235
|
+
const listing = spawnSync(tools.git, [`--git-dir=${gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env, encoding: "utf8", timeout: CONFIG_LIST_MS });
|
|
236
|
+
if (listing.status !== 0) {
|
|
237
|
+
const how = listing.error?.code === "ETIMEDOUT" ? `timed out after ${CONFIG_LIST_MS / 1000} s`
|
|
238
|
+
: listing.signal !== null ? `stopped by ${listing.signal}` : listing.error !== undefined ? "couldn't start" : `exited ${listing.status}`;
|
|
239
|
+
return { ok: false, why: `Git couldn't list the project's settings (${how})` };
|
|
240
|
+
}
|
|
241
|
+
// The key is the repository's own text: named only by kind, never echoed.
|
|
242
|
+
if (releaseConfigProblem(listing.stdout) !== null)
|
|
243
|
+
return { ok: false, why: "the project's Git settings hold an entry a release read refuses" };
|
|
232
244
|
const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
245
|
+
// commit-extract's own words, made one control-free line like any shown text, in the card's lower-case clause form.
|
|
246
|
+
if (!range.ok) {
|
|
247
|
+
const said = subjectText(range.message).replace(/\.$/, "");
|
|
248
|
+
return { ok: false, why: /^[A-Z][a-z]/.test(said) ? said[0].toLowerCase() + said.slice(1) : said };
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, commits: range.value.map(one => ({ sha: one.id, subject: subjectText(one.message) })) };
|
|
251
|
+
}
|
|
252
|
+
/** readCommitsBetween's list, or null when it can't be read. */
|
|
253
|
+
export function commitsBetween(repo, from, to) {
|
|
254
|
+
const read = readCommitsBetween(repo, from, to);
|
|
255
|
+
return read.ok ? read.commits : null;
|
|
236
256
|
}
|
|
237
257
|
/** GitHub lists at most this many of a pull request's commits; a longer one is never shown as complete. */
|
|
238
258
|
export const PULL_REQUEST_COMMITS_MAX = 250;
|
|
@@ -355,7 +375,10 @@ export function releaseChangeList(store, terms, read) {
|
|
|
355
375
|
let source;
|
|
356
376
|
let why;
|
|
357
377
|
if (baseline !== null) {
|
|
358
|
-
|
|
378
|
+
const read = readCommitsBetween(terms.repo, baseline, terms.approvedSha);
|
|
379
|
+
commits = read.ok ? read.commits : null;
|
|
380
|
+
if (!read.ok)
|
|
381
|
+
why = read.why;
|
|
359
382
|
source = commits === null ? "unread" : "recorded";
|
|
360
383
|
}
|
|
361
384
|
else {
|
|
@@ -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
|
+
}
|
|
@@ -21,8 +21,12 @@ export declare function familyCompleted(store: Store, taskId: string): boolean;
|
|
|
21
21
|
export declare function changedFilesOf(store: Store, runId: number, root: string): string[] | null;
|
|
22
22
|
/** Everything a result's branch carries: this build's changes and every build of every task it revises (a
|
|
23
23
|
* revision builds on its source's head, so its branch holds the source's commits too). null when any of
|
|
24
|
-
* them can't be read whole.
|
|
25
|
-
|
|
24
|
+
* them can't be read whole. An earlier build with no recorded diff is skipped by default; with
|
|
25
|
+
* `missing: "unknown"` (the local owner's boundary) it is skipped only when its run record proves it committed
|
|
26
|
+
* nothing, and any other one makes the whole answer unknown. */
|
|
27
|
+
export declare function familyChangedFiles(store: Store, taskId: string, runId: number, root: string, options?: {
|
|
28
|
+
missing?: "skip" | "unknown";
|
|
29
|
+
}): string[] | null;
|
|
26
30
|
/** Why a build's branch must wait before it's pushed (and so before any PR or merge): it (or a task it
|
|
27
31
|
* revises) changed protected files on a one-person approval, and nobody but its approver and requesters
|
|
28
32
|
* has marked it complete. */
|
|
@@ -42,10 +42,12 @@ export function changedFilesOf(store, runId, root) {
|
|
|
42
42
|
}
|
|
43
43
|
/** Everything a result's branch carries: this build's changes and every build of every task it revises (a
|
|
44
44
|
* revision builds on its source's head, so its branch holds the source's commits too). null when any of
|
|
45
|
-
* them can't be read whole.
|
|
46
|
-
|
|
45
|
+
* them can't be read whole. An earlier build with no recorded diff is skipped by default; with
|
|
46
|
+
* `missing: "unknown"` (the local owner's boundary) it is skipped only when its run record proves it committed
|
|
47
|
+
* nothing, and any other one makes the whole answer unknown. */
|
|
48
|
+
export function familyChangedFiles(store, taskId, runId, root, options = {}) {
|
|
47
49
|
const files = new Set();
|
|
48
|
-
const runs = new
|
|
50
|
+
const runs = new Map([[runId, null]]);
|
|
49
51
|
const chain = store.revisionAncestryStatus(taskId).chain;
|
|
50
52
|
for (const id of chain.length === 0 ? [taskId] : chain) {
|
|
51
53
|
const ref = store.lookupRef(id);
|
|
@@ -53,12 +55,16 @@ export function familyChangedFiles(store, taskId, runId, root) {
|
|
|
53
55
|
continue;
|
|
54
56
|
for (const run of store.runsFor(ref.id))
|
|
55
57
|
if ((run.role === "builder" || run.role === "repair") && (id !== taskId || run.id === runId))
|
|
56
|
-
runs.
|
|
58
|
+
runs.set(run.id, run);
|
|
57
59
|
}
|
|
58
|
-
for (const id of runs) {
|
|
60
|
+
for (const [id, run] of runs) {
|
|
59
61
|
// A build that never recorded a diff contributed nothing to the branch; one whose diff can't be read counts as protected.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
// Under "unknown", a missing inventory proves nothing by itself: only a finished run that committed nothing is skipped.
|
|
63
|
+
if (!store.artifactsFor(id).some(one => one.kind === "diff-stat") && id !== runId) {
|
|
64
|
+
if (options.missing !== "unknown" || (run?.committed === false && run.finishedAt !== null))
|
|
65
|
+
continue;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
62
68
|
const changed = changedFilesOf(store, id, root);
|
|
63
69
|
if (changed === null)
|
|
64
70
|
return null;
|
package/dist/scope.d.ts
CHANGED
|
@@ -1044,7 +1044,7 @@ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: D
|
|
|
1044
1044
|
/** When a password typed at `at` (ISO) stops standing in, for a page's words (ISO); null when `at` isn't a time. Words
|
|
1045
1045
|
* only: freshnessFor decides whether it stands in, at the moment of use. */
|
|
1046
1046
|
export declare function freshUntil(at: string): string | null;
|
|
1047
|
-
export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed";
|
|
1047
|
+
export type LocalApprovalRefusal = "local-mode-off" | "not-owner" | "no-scope" | "release" | "protected" | "escalated" | "coordinator" | "unsandboxed" | "imported";
|
|
1048
1048
|
/**
|
|
1049
1049
|
* Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
|
|
1050
1050
|
* preview (the task page's approve form and the chat review render no password field when it is ok), the chat
|
|
@@ -1065,3 +1065,34 @@ export declare function liveChainFromJson(json: string | null): ChainEntry[] | n
|
|
|
1065
1065
|
/** The chain in full, the words a yes agrees to: order, agent, model, how it bills, what it may do, and whether Toolroll
|
|
1066
1066
|
* can see it run out. Every approval surface shows these lines; empty for a scope with no chain. */
|
|
1067
1067
|
export declare function chainTermsLines(scope: Pick<Scope, "proposedChainJson">): string[];
|
|
1068
|
+
/** The act a local owner's work rule is read for: approving a plan, or completing (accepting) a result whose build
|
|
1069
|
+
* changed `changed` (the build's own diff; null when it can't be read, which counts as protected). */
|
|
1070
|
+
export type LocalWorkPurpose = {
|
|
1071
|
+
act: "approve";
|
|
1072
|
+
} | {
|
|
1073
|
+
act: "complete";
|
|
1074
|
+
changed: readonly string[] | null;
|
|
1075
|
+
} | {
|
|
1076
|
+
act: "plan";
|
|
1077
|
+
} | {
|
|
1078
|
+
act: "retry";
|
|
1079
|
+
};
|
|
1080
|
+
/**
|
|
1081
|
+
* Whether the local owner may do this act on this task without a password: ONE rule (easy-start C5, split out for
|
|
1082
|
+
* local-owner slice 0), read inside the act's own transaction. Both acts refuse: local mode off or another person;
|
|
1083
|
+
* release or deploy work; protected or two-person work; work a coordinator filed (or a task with no built-in record).
|
|
1084
|
+
* Approving also refuses no plan, an escalated profile and a project stored as unsandboxed (what a build may do, which
|
|
1085
|
+
* is what an approval agrees to). Completing reads release work from the release records too, and protected work from
|
|
1086
|
+
* what the result actually changed as well as what its scope declared (v102's rule: declared paths are only a promise).
|
|
1087
|
+
* Asking for a plan (local-owner slice 2) reads approve's rule for a task that may have no scope yet (no declared paths
|
|
1088
|
+
* count as protected; the task's own permission mode counts as escalated), the release records too, and also refuses a
|
|
1089
|
+
* task from outside (an imported issue): a new act, so no existing act changes. Retrying (slice 3) is approve's rule
|
|
1090
|
+
* exactly (work the owner could approve again now), plus the release records and a task from outside.
|
|
1091
|
+
*/
|
|
1092
|
+
export declare function localWorkEligible(store: Store, by: string, taskId: string, purpose: LocalWorkPurpose): {
|
|
1093
|
+
ok: true;
|
|
1094
|
+
} | {
|
|
1095
|
+
ok: false;
|
|
1096
|
+
reason: LocalApprovalRefusal;
|
|
1097
|
+
message: string;
|
|
1098
|
+
};
|
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" };
|
|
@@ -1443,11 +1449,12 @@ function describeApproval(approval) {
|
|
|
1443
1449
|
// ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
|
|
1444
1450
|
export { personProof } from "./person-proof.js";
|
|
1445
1451
|
import { personProof } from "./person-proof.js";
|
|
1452
|
+
import { isProtectedWork, protectedChanges } from "./approval-policy.js";
|
|
1446
1453
|
import { permissionPlainWords } from "./chat-terms.js";
|
|
1447
1454
|
import { limitRecognised } from "./exhaustion.js";
|
|
1448
1455
|
import { SUBSCRIPTION_CAPABLE } from "./keys.js";
|
|
1449
1456
|
import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
|
|
1450
|
-
import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem, releaseStateAt } from "./release-authority.js";
|
|
1457
|
+
import { CHECK_ACCESS_WIDTH, autoEvidenceOf, belongsToRelease, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem, releaseStateAt } from "./release-authority.js";
|
|
1451
1458
|
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
1452
1459
|
export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
|
|
1453
1460
|
/** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
|
|
@@ -1703,6 +1710,8 @@ const LOCAL_APPROVAL_WORDS = {
|
|
|
1703
1710
|
escalated: "Work with wider permissions needs your password.",
|
|
1704
1711
|
coordinator: "Work a coordinator filed needs your password.",
|
|
1705
1712
|
unsandboxed: "This project's checks run without a sandbox, so approving needs your password.",
|
|
1713
|
+
// Not an approve refusal (approve keeps today's rule); the type needs words for it.
|
|
1714
|
+
imported: "A task from outside needs your password.",
|
|
1706
1715
|
};
|
|
1707
1716
|
/**
|
|
1708
1717
|
* Whether the local owner may approve this task without a password (easy-start plan C5). ONE rule, read by the
|
|
@@ -1713,30 +1722,8 @@ const LOCAL_APPROVAL_WORDS = {
|
|
|
1713
1722
|
* a scout proposed are allowed.
|
|
1714
1723
|
*/
|
|
1715
1724
|
export function localApprovalEligible(store, by, taskId) {
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
return no("local-mode-off");
|
|
1719
|
-
const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
|
|
1720
|
-
if (owner !== by)
|
|
1721
|
-
return no("not-owner");
|
|
1722
|
-
const scope = store.getScope(taskId);
|
|
1723
|
-
if (scope === null)
|
|
1724
|
-
return no("no-scope");
|
|
1725
|
-
if (deployGateScope(scope) || releaseCheckScope(scope))
|
|
1726
|
-
return no("release");
|
|
1727
|
-
const gate = store.approvalGate(taskId, by, "person");
|
|
1728
|
-
if (gate.verdict !== "seal" || gate.protectedWork)
|
|
1729
|
-
return no("protected");
|
|
1730
|
-
if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
|
|
1731
|
-
return no("escalated");
|
|
1732
|
-
const ref = store.handle.prepare("SELECT repo, coordinator_cid FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
|
|
1733
|
-
if (ref === undefined || ref["coordinator_cid"] != null)
|
|
1734
|
-
return no("coordinator");
|
|
1735
|
-
// The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
|
|
1736
|
-
const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
|
|
1737
|
-
if (stored === "unsandboxed" || scope.profile.checkAccess === "unsandboxed")
|
|
1738
|
-
return no("unsandboxed");
|
|
1739
|
-
return { ok: true };
|
|
1725
|
+
// The one rule, shared with completing a result (localWorkEligible, below).
|
|
1726
|
+
return localWorkEligible(store, by, taskId, { act: "approve" });
|
|
1740
1727
|
}
|
|
1741
1728
|
// ---- Fallback chains (fallback.ts). Kept last too.
|
|
1742
1729
|
/** A chain that may dispatch: version 2, a base and at least one fallback. An older chain is history, never this. */
|
|
@@ -1762,3 +1749,107 @@ export function chainTermsLines(scope) {
|
|
|
1762
1749
|
}),
|
|
1763
1750
|
];
|
|
1764
1751
|
}
|
|
1752
|
+
// ---- Local owner, slice 0 (docs/plans/local-owner-acts.md): one work rule for a no-password act. Kept last.
|
|
1753
|
+
/** What a local owner hears when a result isn't one it may accept without a password. */
|
|
1754
|
+
const LOCAL_COMPLETION_WORDS = {
|
|
1755
|
+
"local-mode-off": "Local mode is off, so accepting this result needs your password.",
|
|
1756
|
+
"not-owner": "Only this computer's owner accepts results here without a password.",
|
|
1757
|
+
"no-scope": "This task has no plan, so accepting its result needs your password.",
|
|
1758
|
+
release: "Accepting a release needs your password.",
|
|
1759
|
+
protected: "This result is protected work, so accepting it needs your password and a second person.",
|
|
1760
|
+
escalated: "Work with wider permissions needs your password.",
|
|
1761
|
+
coordinator: "Work a coordinator filed needs your password to accept.",
|
|
1762
|
+
unsandboxed: "This project's checks run without a sandbox, so accepting needs your password.",
|
|
1763
|
+
// Not a completion refusal (completion keeps slice 0's rule); the type needs words for it.
|
|
1764
|
+
imported: "A task from outside needs your password to accept.",
|
|
1765
|
+
};
|
|
1766
|
+
/** What a local owner hears when a task isn't one it may ask a plan for without a password (local-owner slice 2). */
|
|
1767
|
+
const LOCAL_PLAN_WORDS = {
|
|
1768
|
+
"local-mode-off": "Local mode is off, so asking for a plan needs your password.",
|
|
1769
|
+
"not-owner": "Only this computer's owner asks for plans here without a password.",
|
|
1770
|
+
"no-scope": "This task has nothing to plan yet.",
|
|
1771
|
+
release: "Planning a release needs your password.",
|
|
1772
|
+
protected: "This is protected work, so planning it needs your password.",
|
|
1773
|
+
escalated: "Work with wider permissions needs your password to plan.",
|
|
1774
|
+
coordinator: "Work a coordinator filed needs your password to plan.",
|
|
1775
|
+
unsandboxed: "This project's checks run without a sandbox, so planning needs your password.",
|
|
1776
|
+
imported: "A task from outside needs your password to plan.",
|
|
1777
|
+
};
|
|
1778
|
+
/** What a local owner hears when a build isn't one it may retry without a password (local-owner slice 3). */
|
|
1779
|
+
const LOCAL_RETRY_WORDS = {
|
|
1780
|
+
"local-mode-off": "Local mode is off, so retrying needs your password.",
|
|
1781
|
+
"not-owner": "Only this computer's owner retries builds here without a password.",
|
|
1782
|
+
"no-scope": "This task has no approved plan to build again.",
|
|
1783
|
+
release: "Retrying a release needs your password.",
|
|
1784
|
+
protected: "This is protected work, so retrying it needs your password.",
|
|
1785
|
+
escalated: "Work with wider permissions needs your password to retry.",
|
|
1786
|
+
coordinator: "Work a coordinator filed needs your password to retry.",
|
|
1787
|
+
unsandboxed: "This project's checks run without a sandbox, so retrying needs your password.",
|
|
1788
|
+
imported: "A task from outside needs your password to retry.",
|
|
1789
|
+
};
|
|
1790
|
+
/**
|
|
1791
|
+
* Whether the local owner may do this act on this task without a password: ONE rule (easy-start C5, split out for
|
|
1792
|
+
* local-owner slice 0), read inside the act's own transaction. Both acts refuse: local mode off or another person;
|
|
1793
|
+
* release or deploy work; protected or two-person work; work a coordinator filed (or a task with no built-in record).
|
|
1794
|
+
* Approving also refuses no plan, an escalated profile and a project stored as unsandboxed (what a build may do, which
|
|
1795
|
+
* is what an approval agrees to). Completing reads release work from the release records too, and protected work from
|
|
1796
|
+
* what the result actually changed as well as what its scope declared (v102's rule: declared paths are only a promise).
|
|
1797
|
+
* Asking for a plan (local-owner slice 2) reads approve's rule for a task that may have no scope yet (no declared paths
|
|
1798
|
+
* count as protected; the task's own permission mode counts as escalated), the release records too, and also refuses a
|
|
1799
|
+
* task from outside (an imported issue): a new act, so no existing act changes. Retrying (slice 3) is approve's rule
|
|
1800
|
+
* exactly (work the owner could approve again now), plus the release records and a task from outside.
|
|
1801
|
+
*/
|
|
1802
|
+
export function localWorkEligible(store, by, taskId, purpose) {
|
|
1803
|
+
const words = purpose.act === "approve" ? LOCAL_APPROVAL_WORDS : purpose.act === "complete" ? LOCAL_COMPLETION_WORDS : purpose.act === "plan" ? LOCAL_PLAN_WORDS : LOCAL_RETRY_WORDS;
|
|
1804
|
+
const no = (reason) => ({ ok: false, reason, message: words[reason] });
|
|
1805
|
+
if (!localModeOn(store))
|
|
1806
|
+
return no("local-mode-off");
|
|
1807
|
+
const owner = store.handle.prepare("SELECT owner FROM local_mode WHERE id = 1").get()?.["owner"];
|
|
1808
|
+
if (owner !== by)
|
|
1809
|
+
return no("not-owner");
|
|
1810
|
+
const scope = store.getScope(taskId);
|
|
1811
|
+
if ((purpose.act === "approve" || purpose.act === "retry") && scope === null)
|
|
1812
|
+
return no("no-scope");
|
|
1813
|
+
if (scope !== null && (deployGateScope(scope) || releaseCheckScope(scope)))
|
|
1814
|
+
return no("release");
|
|
1815
|
+
if (purpose.act !== "approve" && (belongsToRelease(store, taskId) || releaseAuthorityOfTask(store, taskId) !== null))
|
|
1816
|
+
return no("release");
|
|
1817
|
+
const ref = store.handle.prepare("SELECT repo, coordinator_cid, permission_mode FROM task_ref WHERE backend = 'built-in' AND external_id = ?").get(taskId);
|
|
1818
|
+
// Retrying spends again under an existing yes: only on work the owner could approve again now (approve's own rule).
|
|
1819
|
+
if (purpose.act === "approve" || purpose.act === "retry") {
|
|
1820
|
+
const gate = store.approvalGate(taskId, by, "person");
|
|
1821
|
+
if (gate.verdict !== "seal" || gate.protectedWork)
|
|
1822
|
+
return no("protected");
|
|
1823
|
+
if (scope.profile == null || levelOfProfile(scope.profile) === "escalated")
|
|
1824
|
+
return no("escalated");
|
|
1825
|
+
}
|
|
1826
|
+
else if (purpose.act === "plan") {
|
|
1827
|
+
// Approve's rule, read for a task that may have no scope yet: protected or two-person work (no declared paths count
|
|
1828
|
+
// as protected), and an escalated profile or task-level permission mode.
|
|
1829
|
+
const gate = store.approvalGate(taskId, by, "person");
|
|
1830
|
+
if (gate.verdict !== "seal" || gate.protectedWork)
|
|
1831
|
+
return no("protected");
|
|
1832
|
+
if ((scope?.profile != null && levelOfProfile(scope.profile) === "escalated") || ref?.["permission_mode"] === "bypassPermissions")
|
|
1833
|
+
return no("escalated");
|
|
1834
|
+
}
|
|
1835
|
+
else if (ref !== undefined && ref["repo"] != null) {
|
|
1836
|
+
// Declared (no declared paths counts as protected) or actually changed (an unreadable diff counts as changed).
|
|
1837
|
+
const rules = store.approvalRules(String(ref["repo"]));
|
|
1838
|
+
if ((rules.protectProject || rules.protectedPaths.length > 0)
|
|
1839
|
+
&& (isProtectedWork(rules, scope?.touches ?? []) || purpose.changed === null || protectedChanges(rules, purpose.changed).length > 0))
|
|
1840
|
+
return no("protected");
|
|
1841
|
+
}
|
|
1842
|
+
if (ref === undefined || ref["coordinator_cid"] != null)
|
|
1843
|
+
return no("coordinator");
|
|
1844
|
+
// What the build may do was agreed at its approval; a result already built is judged by what it is and changed.
|
|
1845
|
+
if (purpose.act === "complete")
|
|
1846
|
+
return { ok: true };
|
|
1847
|
+
// Asking for a plan (a new act, so no behaviour changes): never a task from outside (a GitHub import, or a revision of one).
|
|
1848
|
+
if ((purpose.act === "plan" || purpose.act === "retry") && issueImported(store, taskId))
|
|
1849
|
+
return no("imported");
|
|
1850
|
+
// The stored level, never the launch cap: a project saved as unsandboxed is refused even though local mode confines it.
|
|
1851
|
+
const stored = ref["repo"] == null ? undefined : store.handle.prepare("SELECT level FROM project_check_access WHERE repo = ?").get(ref["repo"])?.["level"];
|
|
1852
|
+
if (stored === "unsandboxed" || scope?.profile?.checkAccess === "unsandboxed")
|
|
1853
|
+
return no("unsandboxed");
|
|
1854
|
+
return { ok: true };
|
|
1855
|
+
}
|