toolroll 0.9.7 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-adapters.js +2 -0
- package/dist/assignment.d.ts +6 -0
- package/dist/assignment.js +18 -3
- package/dist/build-review.d.ts +38 -0
- package/dist/build-review.js +341 -0
- package/dist/check-levels-ui.d.ts +5 -0
- package/dist/check-levels-ui.js +16 -2
- package/dist/cli.js +2 -0
- package/dist/dispatch.js +2 -1
- package/dist/dispose.d.ts +4 -3
- package/dist/dispose.js +12 -4
- package/dist/exec.d.ts +5 -0
- package/dist/exec.js +30 -4
- package/dist/first-run.d.ts +1 -1
- package/dist/first-run.js +1 -1
- package/dist/invoke.js +5 -0
- package/dist/modes.d.ts +3 -1
- package/dist/modes.js +3 -1
- package/dist/operate.js +119 -22
- package/dist/process-custody.js +8 -0
- package/dist/provider.js +16 -0
- package/dist/retention-ui.d.ts +3 -1
- package/dist/retention-ui.js +4 -4
- package/dist/retention.d.ts +13 -6
- package/dist/retention.js +50 -12
- package/dist/review-switch.d.ts +41 -0
- package/dist/review-switch.js +88 -0
- package/dist/reviewer.d.ts +24 -2
- package/dist/reviewer.js +49 -1
- package/dist/serve.js +29 -4
- package/dist/store.d.ts +36 -1
- package/dist/store.js +136 -5
- package/dist/surface.js +5 -1
- package/dist/task-status.d.ts +5 -2
- package/dist/task-status.js +10 -6
- package/dist/work-summary.js +2 -1
- package/dist/workspace-ui.d.ts +3 -1
- package/dist/workspace-ui.js +9 -8
- package/package.json +1 -1
package/dist/retention.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Retention (v105): how long Toolroll keeps run evidence and logs,
|
|
3
|
-
* finished checkouts' records, chat messages and notifications.
|
|
4
|
-
*
|
|
5
|
-
* a daily sweep
|
|
6
|
-
* ledger entry saying what went and about how much
|
|
3
|
+
* finished checkouts' records, chat messages and notifications. Until an
|
|
4
|
+
* instance operator chooses a period, evidence is kept 28 days and every
|
|
5
|
+
* other kind forever; a daily sweep deletes what is older than its period
|
|
6
|
+
* and writes one action ledger entry saying what went and about how much
|
|
7
|
+
* space it freed.
|
|
7
8
|
*
|
|
8
9
|
* Never deleted, whatever the settings: the action ledger (it is append-only),
|
|
9
10
|
* and anything a task still needs. A task still needs its things until it is
|
|
@@ -21,24 +22,47 @@ import { join } from "node:path";
|
|
|
21
22
|
import { RETENTION_NOTE } from "./evidence.js";
|
|
22
23
|
import { COMPLETION_ACTION } from "./result-completion.js";
|
|
23
24
|
import { bytesWords, treeBytes } from "./storage.js";
|
|
24
|
-
|
|
25
|
-
CREATE TABLE IF NOT EXISTS
|
|
25
|
+
const RETENTION_TABLE = (name) => `
|
|
26
|
+
CREATE TABLE IF NOT EXISTS ${name} (
|
|
26
27
|
kind TEXT PRIMARY KEY CHECK (kind IN ('evidence', 'checkouts', 'chat', 'notifications')),
|
|
27
28
|
-- NULL is forever; a row only exists once someone chose.
|
|
28
|
-
days INTEGER CHECK (days IS NULL OR (days >=
|
|
29
|
+
days INTEGER CHECK (days IS NULL OR (days >= 1 AND days <= 3650)),
|
|
29
30
|
updated_by TEXT NOT NULL,
|
|
30
31
|
updated_at TEXT NOT NULL
|
|
31
32
|
);
|
|
32
33
|
`;
|
|
34
|
+
export const RETENTION_SCHEMA = RETENTION_TABLE("retention_setting");
|
|
35
|
+
/** A file from before 1-day evidence keeps `days >= 7` in its CHECK: rebuild the table with the wider one, keeping
|
|
36
|
+
* every row, in one transaction. A file already widened is untouched. */
|
|
37
|
+
export function widenRetentionSchema(db) {
|
|
38
|
+
const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'retention_setting'").get();
|
|
39
|
+
if (row === undefined || !/days\s*>=\s*7\b/.test(String(row["sql"])))
|
|
40
|
+
return;
|
|
41
|
+
db.exec("BEGIN IMMEDIATE");
|
|
42
|
+
try {
|
|
43
|
+
db.exec(RETENTION_TABLE("retention_setting_next"));
|
|
44
|
+
db.exec("INSERT INTO retention_setting_next (kind, days, updated_by, updated_at) SELECT kind, days, updated_by, updated_at FROM retention_setting");
|
|
45
|
+
db.exec("DROP TABLE retention_setting");
|
|
46
|
+
db.exec("ALTER TABLE retention_setting_next RENAME TO retention_setting");
|
|
47
|
+
db.exec("COMMIT");
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
db.exec("ROLLBACK");
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
33
54
|
export const RETENTION_KINDS = [
|
|
34
55
|
{ kind: "evidence", label: "Run evidence and logs", detail: "Diffs, check logs, screenshots and reports saved for each run" },
|
|
35
56
|
{ kind: "checkouts", label: "Finished checkout records", detail: "Records of build checkouts that were let go and are no longer on disk" },
|
|
36
57
|
{ kind: "chat", label: "Chat messages", detail: "Messages in lead and team chats" },
|
|
37
58
|
{ kind: "notifications", label: "Notifications", detail: "Delivered or dismissed notifications" },
|
|
38
59
|
];
|
|
39
|
-
/** The periods the page offers; the command line takes any number of days from
|
|
40
|
-
|
|
41
|
-
export const
|
|
60
|
+
/** The periods the page offers for each kind; the command line takes any number of days from 1 to 3650. */
|
|
61
|
+
const LONG_CHOICES = [30, 90, 180, 365, 730, null];
|
|
62
|
+
export const PERIOD_CHOICES = {
|
|
63
|
+
evidence: [1, 7, 14, 28, null], checkouts: LONG_CHOICES, chat: LONG_CHOICES, notifications: LONG_CHOICES,
|
|
64
|
+
};
|
|
65
|
+
export const MIN_DAYS = 1;
|
|
42
66
|
export const MAX_DAYS = 3650;
|
|
43
67
|
const DAY_MS = 86_400_000;
|
|
44
68
|
/** A sweep is due once a day. */
|
|
@@ -46,7 +70,8 @@ export const SWEEP_EVERY_MS = DAY_MS;
|
|
|
46
70
|
/** At most this many runs, messages, notifications or records go per kind per sweep; the next sweep takes the rest. */
|
|
47
71
|
export const SWEEP_BATCH = 2000;
|
|
48
72
|
const SWEEP_CURSOR = "retention:last-sweep";
|
|
49
|
-
|
|
73
|
+
/** What each kind keeps until someone chooses: evidence 28 days, everything else forever. */
|
|
74
|
+
export const DEFAULT_PERIODS = { evidence: 28, checkouts: null, chat: null, notifications: null };
|
|
50
75
|
export function isRetentionKind(value) {
|
|
51
76
|
return RETENTION_KINDS.some(one => one.kind === value);
|
|
52
77
|
}
|
|
@@ -67,7 +92,20 @@ export function periodWords(days) {
|
|
|
67
92
|
return "forever";
|
|
68
93
|
if (days % 365 === 0)
|
|
69
94
|
return days === 365 ? "1 year" : `${days / 365} years`;
|
|
70
|
-
return `${days} days`;
|
|
95
|
+
return days === 1 ? "1 day" : `${days} days`;
|
|
96
|
+
}
|
|
97
|
+
/** What the page offers for `kind`, with `current` among them (in order) when it was set some other way. */
|
|
98
|
+
export function periodChoices(kind, current) {
|
|
99
|
+
const offered = PERIOD_CHOICES[kind];
|
|
100
|
+
return offered.includes(current) ? offered : [...offered, current].sort((a, b) => (a ?? Infinity) - (b ?? Infinity));
|
|
101
|
+
}
|
|
102
|
+
/** A period as the page and `retention show` say it: "28 days (default)" when nobody chose, "30 days (custom)" when it's not one the page offers. */
|
|
103
|
+
export function periodLabel(kind, days, chosen) {
|
|
104
|
+
const words = periodWords(days);
|
|
105
|
+
const label = `${words[0].toUpperCase()}${words.slice(1)}`;
|
|
106
|
+
if (!chosen)
|
|
107
|
+
return `${label} (default)`;
|
|
108
|
+
return PERIOD_CHOICES[kind].includes(days) ? label : `${label} (custom)`;
|
|
71
109
|
}
|
|
72
110
|
/** Tasks that no longer need anything kept: cancelled, or done with their result marked complete; not on hold, not
|
|
73
111
|
* running, not a release candidate, and no revision below it (at any depth) still unfinished — cancelled or completed,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The per-project automatic review switch and the one review each finished
|
|
2
|
+
* build gets (build-review.ts runs it): tables, and the read-only views every
|
|
3
|
+
* surface shows. No schema version bump. Kept free of heavy imports so the
|
|
4
|
+
* status readers can use it without an import cycle. */
|
|
5
|
+
import type { Store } from "./store.js";
|
|
6
|
+
export type FindingSeverity = "HIGH" | "MEDIUM" | "LOW";
|
|
7
|
+
/** One automatic-review finding: how bad, where, and how it fails. */
|
|
8
|
+
export type BuildFinding = {
|
|
9
|
+
severity: FindingSeverity;
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
scenario: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const REVIEW_SCHEMA = "\n-- A project's explicit review switch. No row: on while the project is under a hands-off mode, off otherwise.\nCREATE TABLE IF NOT EXISTS review_switch (\n repo TEXT PRIMARY KEY,\n enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),\n changed_by TEXT NOT NULL,\n changed_at TEXT NOT NULL\n);\n-- One row per reviewed build run: pending until its one reviewer run settles it.\nCREATE TABLE IF NOT EXISTS build_review (\n run INTEGER PRIMARY KEY,\n task_id TEXT NOT NULL,\n repo TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('pending', 'reviewed', 'not-reviewed')),\n request INTEGER,\n reviewer_run INTEGER,\n findings_json TEXT,\n reason TEXT,\n sent_back_as TEXT,\n queued_at TEXT NOT NULL,\n finished_at TEXT\n);\nCREATE INDEX IF NOT EXISTS build_review_pending ON build_review (repo) WHERE state = 'pending';\nCREATE INDEX IF NOT EXISTS build_review_sent_back ON build_review (sent_back_as) WHERE sent_back_as IS NOT NULL;\n";
|
|
15
|
+
export type ReviewSwitch = {
|
|
16
|
+
on: boolean;
|
|
17
|
+
source: "project" | "hands-off" | "default";
|
|
18
|
+
changedBy: string | null;
|
|
19
|
+
changedAt: string | null;
|
|
20
|
+
};
|
|
21
|
+
/** How the policy log words a switch state. */
|
|
22
|
+
export declare function reviewSwitchWords(state: ReviewSwitch): string;
|
|
23
|
+
export type BuildReviewView = {
|
|
24
|
+
run: number;
|
|
25
|
+
state: "pending" | "reviewed" | "not-reviewed";
|
|
26
|
+
reviewerRun: number | null;
|
|
27
|
+
/** Why it was not reviewed, in words. */
|
|
28
|
+
reason: string | null;
|
|
29
|
+
/** HIGH findings; they sent the task back when `sentBackAs` is set. */
|
|
30
|
+
high: BuildFinding[];
|
|
31
|
+
/** MEDIUM and LOW findings: suggested follow-ups, never blocking. */
|
|
32
|
+
followUps: BuildFinding[];
|
|
33
|
+
sentBackAs: string | null;
|
|
34
|
+
finishedAt: string | null;
|
|
35
|
+
};
|
|
36
|
+
export declare function buildReviewOf(store: Pick<Store, "handle">, runId: number): BuildReviewView | null;
|
|
37
|
+
export declare const findingWords: (finding: BuildFinding) => string;
|
|
38
|
+
/** The review's standing in one line, or null while nothing was asked. */
|
|
39
|
+
export declare function buildReviewHeadline(view: BuildReviewView | null): string | null;
|
|
40
|
+
/** Plain lines for `task show` and `assignment show`. */
|
|
41
|
+
export declare function buildReviewLines(view: BuildReviewView | null): string[];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const REVIEW_SCHEMA = `
|
|
2
|
+
-- A project's explicit review switch. No row: on while the project is under a hands-off mode, off otherwise.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS review_switch (
|
|
4
|
+
repo TEXT PRIMARY KEY,
|
|
5
|
+
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
|
6
|
+
changed_by TEXT NOT NULL,
|
|
7
|
+
changed_at TEXT NOT NULL
|
|
8
|
+
);
|
|
9
|
+
-- One row per reviewed build run: pending until its one reviewer run settles it.
|
|
10
|
+
CREATE TABLE IF NOT EXISTS build_review (
|
|
11
|
+
run INTEGER PRIMARY KEY,
|
|
12
|
+
task_id TEXT NOT NULL,
|
|
13
|
+
repo TEXT NOT NULL,
|
|
14
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'reviewed', 'not-reviewed')),
|
|
15
|
+
request INTEGER,
|
|
16
|
+
reviewer_run INTEGER,
|
|
17
|
+
findings_json TEXT,
|
|
18
|
+
reason TEXT,
|
|
19
|
+
sent_back_as TEXT,
|
|
20
|
+
queued_at TEXT NOT NULL,
|
|
21
|
+
finished_at TEXT
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS build_review_pending ON build_review (repo) WHERE state = 'pending';
|
|
24
|
+
CREATE INDEX IF NOT EXISTS build_review_sent_back ON build_review (sent_back_as) WHERE sent_back_as IS NOT NULL;
|
|
25
|
+
`;
|
|
26
|
+
/** How the policy log words a switch state. */
|
|
27
|
+
export function reviewSwitchWords(state) {
|
|
28
|
+
return state.source === "project" ? (state.on ? "on" : "off") : state.on ? "on (hands-off default)" : "off (default)";
|
|
29
|
+
}
|
|
30
|
+
export function buildReviewOf(store, runId) {
|
|
31
|
+
let row;
|
|
32
|
+
// A deploy reads this with the candidate's code over the INSTALLED runtime's file, before this version's migration
|
|
33
|
+
// made the table: no table reads as no review.
|
|
34
|
+
try {
|
|
35
|
+
row = store.handle.prepare("SELECT * FROM build_review WHERE run = ?").get(runId);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (/no such table: build_review/.test(String(error)))
|
|
39
|
+
return null;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
if (row === undefined)
|
|
43
|
+
return null;
|
|
44
|
+
// The row was validated when it was written (parseBuildFindings).
|
|
45
|
+
let findings = [];
|
|
46
|
+
try {
|
|
47
|
+
const saved = row["findings_json"] === null ? null : JSON.parse(String(row["findings_json"]));
|
|
48
|
+
if (Array.isArray(saved?.findings))
|
|
49
|
+
findings = saved.findings;
|
|
50
|
+
}
|
|
51
|
+
catch { /* An unreadable saved row shows no findings; its state stays. */ }
|
|
52
|
+
return {
|
|
53
|
+
run: runId,
|
|
54
|
+
state: String(row["state"]),
|
|
55
|
+
reviewerRun: row["reviewer_run"] === null ? null : Number(row["reviewer_run"]),
|
|
56
|
+
reason: row["reason"] === null ? null : String(row["reason"]),
|
|
57
|
+
high: findings.filter(one => one.severity === "HIGH"),
|
|
58
|
+
followUps: findings.filter(one => one.severity !== "HIGH"),
|
|
59
|
+
sentBackAs: row["sent_back_as"] === null ? null : String(row["sent_back_as"]),
|
|
60
|
+
finishedAt: row["finished_at"] === null ? null : String(row["finished_at"]),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export const findingWords = (finding) => `${finding.severity} ${finding.file}:${finding.line} — ${finding.scenario}`;
|
|
64
|
+
/** The review's standing in one line, or null while nothing was asked. */
|
|
65
|
+
export function buildReviewHeadline(view) {
|
|
66
|
+
if (view === null)
|
|
67
|
+
return null;
|
|
68
|
+
if (view.state === "pending")
|
|
69
|
+
return "Reviewing — an automatic review is reading this result.";
|
|
70
|
+
if (view.state === "not-reviewed")
|
|
71
|
+
return `Not reviewed${view.reason === null ? "." : `: ${view.reason}`}`;
|
|
72
|
+
if (view.sentBackAs !== null)
|
|
73
|
+
return `Sent back as ${view.sentBackAs}: ${view.high.length} high finding${view.high.length === 1 ? "" : "s"}.`;
|
|
74
|
+
if (view.high.length > 0)
|
|
75
|
+
return `Reviewed: ${view.high.length} high finding${view.high.length === 1 ? "" : "s"} for you to decide.`;
|
|
76
|
+
return `Reviewed: no high findings${view.followUps.length === 0 ? "." : `; ${view.followUps.length} suggested follow-up${view.followUps.length === 1 ? "" : "s"}.`}`;
|
|
77
|
+
}
|
|
78
|
+
/** Plain lines for `task show` and `assignment show`. */
|
|
79
|
+
export function buildReviewLines(view) {
|
|
80
|
+
const headline = buildReviewHeadline(view);
|
|
81
|
+
if (view === null || headline === null)
|
|
82
|
+
return [];
|
|
83
|
+
return [
|
|
84
|
+
` review: ${headline}`,
|
|
85
|
+
...view.high.map(one => ` ${findingWords(one)}`),
|
|
86
|
+
...view.followUps.map(one => ` suggested follow-up: ${findingWords(one)}`),
|
|
87
|
+
];
|
|
88
|
+
}
|
package/dist/reviewer.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
/** Pure
|
|
2
|
-
*
|
|
1
|
+
/** Pure parsers for review payloads: retained historical reviews, and the one
|
|
2
|
+
* automatic build review's findings (build-review.ts runs it). This module
|
|
3
|
+
* cannot launch providers, create runs or retry work. */
|
|
3
4
|
import type { CriterionJudgementWord } from "./proof.js";
|
|
5
|
+
import type { BuildFinding } from "./review-switch.js";
|
|
4
6
|
/** The files the pass writes INTO the scratch directory for the agent —
|
|
5
7
|
* the patch always; the rubric, re-serialized proof, check log, and
|
|
6
8
|
* screenshots only when this run actually has one (v40) — a grandfathered
|
|
@@ -95,3 +97,23 @@ export declare function parseReview(raw: string, patchPaths: ReadonlySet<string>
|
|
|
95
97
|
ok: false;
|
|
96
98
|
problems: ReviewProblem[];
|
|
97
99
|
};
|
|
100
|
+
export type { BuildFinding, FindingSeverity } from "./review-switch.js";
|
|
101
|
+
export declare const FINDING_LIMITS: {
|
|
102
|
+
readonly findings: 40;
|
|
103
|
+
readonly file: 300;
|
|
104
|
+
readonly scenario: 500;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* The automatic build review's reply, strict and wholesale like parseReview:
|
|
108
|
+
* `{"version":1,"findings":[{"severity","file","line","scenario"}]}`. Any
|
|
109
|
+
* invalid finding refuses the whole reply — a review that did not answer
|
|
110
|
+
* what it was asked is "not reviewed", never a partial pass. Text is kept
|
|
111
|
+
* to one line so it can travel in a revision note and a terminal.
|
|
112
|
+
*/
|
|
113
|
+
export declare function parseBuildFindings(raw: string): {
|
|
114
|
+
ok: true;
|
|
115
|
+
findings: BuildFinding[];
|
|
116
|
+
} | {
|
|
117
|
+
ok: false;
|
|
118
|
+
problem: string;
|
|
119
|
+
};
|
package/dist/reviewer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { citesSuppliedProvenance } from "./review-context.js";
|
|
2
|
-
import { REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
|
|
2
|
+
import { normalizeStructuredJson, REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
|
|
3
3
|
/** The files the pass writes INTO the scratch directory for the agent —
|
|
4
4
|
* the patch always; the rubric, re-serialized proof, check log, and
|
|
5
5
|
* screenshots only when this run actually has one (v40) — a grandfathered
|
|
@@ -200,3 +200,51 @@ export function parseReview(raw, patchPaths, approvedCriteriaIds = new Set(), pr
|
|
|
200
200
|
return { ok: false, problems };
|
|
201
201
|
return { ok: true, comments, criteria, ...(payload["learning"] === undefined ? {} : { learning: payload["learning"] }), ...(payload["learningAssessment"] === undefined ? {} : { learningAssessment: payload["learningAssessment"] }) };
|
|
202
202
|
}
|
|
203
|
+
export const FINDING_LIMITS = { findings: 40, file: REVIEW_OUTPUT_LIMITS.path, scenario: REVIEW_OUTPUT_LIMITS.note };
|
|
204
|
+
/**
|
|
205
|
+
* The automatic build review's reply, strict and wholesale like parseReview:
|
|
206
|
+
* `{"version":1,"findings":[{"severity","file","line","scenario"}]}`. Any
|
|
207
|
+
* invalid finding refuses the whole reply — a review that did not answer
|
|
208
|
+
* what it was asked is "not reviewed", never a partial pass. Text is kept
|
|
209
|
+
* to one line so it can travel in a revision note and a terminal.
|
|
210
|
+
*/
|
|
211
|
+
export function parseBuildFindings(raw) {
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = JSON.parse(normalizeStructuredJson(raw).text);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return { ok: false, problem: "the reply is not JSON" };
|
|
218
|
+
}
|
|
219
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
220
|
+
return { ok: false, problem: "the reply is not an object" };
|
|
221
|
+
const payload = parsed;
|
|
222
|
+
if (payload["version"] !== 1)
|
|
223
|
+
return { ok: false, problem: "version must be 1" };
|
|
224
|
+
const list = payload["findings"];
|
|
225
|
+
if (!Array.isArray(list))
|
|
226
|
+
return { ok: false, problem: "findings must be an array" };
|
|
227
|
+
if (list.length > FINDING_LIMITS.findings)
|
|
228
|
+
return { ok: false, problem: `at most ${FINDING_LIMITS.findings} findings` };
|
|
229
|
+
const findings = [];
|
|
230
|
+
const oneLine = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, " ").replace(/\s+/g, " ").trim();
|
|
231
|
+
for (const [index, one] of list.entries()) {
|
|
232
|
+
if (one === null || typeof one !== "object" || Array.isArray(one))
|
|
233
|
+
return { ok: false, problem: `finding ${index}: not an object` };
|
|
234
|
+
const finding = one;
|
|
235
|
+
const severity = finding["severity"];
|
|
236
|
+
if (severity !== "HIGH" && severity !== "MEDIUM" && severity !== "LOW")
|
|
237
|
+
return { ok: false, problem: `finding ${index}: severity must be HIGH, MEDIUM or LOW` };
|
|
238
|
+
const file = typeof finding["file"] === "string" ? oneLine(finding["file"]) : "";
|
|
239
|
+
if (file.length === 0 || file.length > FINDING_LIMITS.file)
|
|
240
|
+
return { ok: false, problem: `finding ${index}: file must be a path of 1..${FINDING_LIMITS.file} characters` };
|
|
241
|
+
const line = finding["line"];
|
|
242
|
+
if (typeof line !== "number" || !Number.isInteger(line) || line < 1)
|
|
243
|
+
return { ok: false, problem: `finding ${index}: line must be a positive whole number` };
|
|
244
|
+
const scenario = typeof finding["scenario"] === "string" ? oneLine(finding["scenario"]) : "";
|
|
245
|
+
if (scenario.length === 0 || scenario.length > FINDING_LIMITS.scenario)
|
|
246
|
+
return { ok: false, problem: `finding ${index}: scenario must be one sentence of 1..${FINDING_LIMITS.scenario} characters` };
|
|
247
|
+
findings.push({ severity, file, line, scenario });
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, findings };
|
|
250
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -229,11 +229,12 @@ import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./
|
|
|
229
229
|
import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
|
|
230
230
|
import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
|
|
231
231
|
import { latestVersionNow } from "./releases.js";
|
|
232
|
-
import { RETENTION_KINDS, lastSweepAt, parsePeriod, retentionPlan } from "./retention.js";
|
|
232
|
+
import { RETENTION_KINDS, lastSweepAt, parsePeriod, periodChoices, retentionPlan } from "./retention.js";
|
|
233
233
|
import { EXPORT_CSS, dataExportHtml } from "./export-ui.js";
|
|
234
234
|
import { buildExport, exportZip } from "./export.js";
|
|
235
235
|
import { PROJECT_DELETE_CSS, projectDeleteConfirmHtml, projectSettingsHtml } from "./project-delete-ui.js";
|
|
236
236
|
import { parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, projectConcurrency, saveProjectConcurrency } from "./project-concurrency.js";
|
|
237
|
+
import { buildReviewOf, findingWords } from "./review-switch.js";
|
|
237
238
|
import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
|
|
238
239
|
import { POLICY_CSS, policyHtml } from "./policy-ui.js";
|
|
239
240
|
import { checkPolicy, parseList, policyParts } from "./policy.js";
|
|
@@ -3898,7 +3899,7 @@ export function createDecisionServer(options) {
|
|
|
3898
3899
|
if (url.pathname === "/settings/retention") {
|
|
3899
3900
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
3900
3901
|
return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
|
|
3901
|
-
const view = { periods: store.retentionPeriods(), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
|
|
3902
|
+
const view = { periods: store.retentionPeriods(), chosen: Object.keys(store.retentionChosen()), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
|
|
3902
3903
|
return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
3903
3904
|
}
|
|
3904
3905
|
// Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
|
|
@@ -3933,6 +3934,7 @@ export function createDecisionServer(options) {
|
|
|
3933
3934
|
const quick = liveQuickCommand(store, repo);
|
|
3934
3935
|
const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
|
|
3935
3936
|
level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
|
|
3937
|
+
review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())),
|
|
3936
3938
|
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
3937
3939
|
return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
3938
3940
|
}
|
|
@@ -5187,7 +5189,7 @@ export function createDecisionServer(options) {
|
|
|
5187
5189
|
const reviewFactsFor = (runId) => reviewFactsOf(store.reviewRetryStateOf(runId), runner => {
|
|
5188
5190
|
const one = store.getRunner(runner)?.runner;
|
|
5189
5191
|
return one !== undefined && runnerAlive(one, clock());
|
|
5190
|
-
});
|
|
5192
|
+
}, buildReviewOf(store, runId)?.state === "pending");
|
|
5191
5193
|
/** Keep detailed receipt diagnostics; shared assignment reads own readiness. */
|
|
5192
5194
|
function freshAssignment(assignment, receipt) {
|
|
5193
5195
|
if (assignment === null)
|
|
@@ -7264,7 +7266,9 @@ export function createDecisionServer(options) {
|
|
|
7264
7266
|
if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
|
|
7265
7267
|
return back("problem", "Enter your Toolroll password to change retention.");
|
|
7266
7268
|
const before = store.retentionPeriods();
|
|
7267
|
-
|
|
7269
|
+
// Only what the page offers (or the period already set): a hand-made value doesn't save.
|
|
7270
|
+
const offered = (kind, days) => days !== undefined && periodChoices(kind, before[kind]).includes(days) ? days : undefined;
|
|
7271
|
+
const chosen = RETENTION_KINDS.map(({ kind }) => ({ kind, days: body.has(kind) ? offered(kind, parsePeriod(body.get(kind) ?? "")) : before[kind] }));
|
|
7268
7272
|
if (chosen.some(one => one.days === undefined))
|
|
7269
7273
|
return back("problem", "Choose a period for each kind of data.");
|
|
7270
7274
|
const changed = chosen.filter(one => one.days !== before[one.kind]);
|
|
@@ -7354,6 +7358,12 @@ export function createDecisionServer(options) {
|
|
|
7354
7358
|
if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
|
|
7355
7359
|
return back("problem", "Enter your Toolroll password to change checks.");
|
|
7356
7360
|
const act = body.get("act");
|
|
7361
|
+
// The automatic review switch: the policy log keeps before → after.
|
|
7362
|
+
if (act === "review") {
|
|
7363
|
+
const on = body.get("on") === "1";
|
|
7364
|
+
store.setReviewSwitch(repo, on, who.name, now);
|
|
7365
|
+
return back("said", on ? "Automatic review is on." : "Automatic review is off.");
|
|
7366
|
+
}
|
|
7357
7367
|
if (act === "level") {
|
|
7358
7368
|
const level = body.get("level");
|
|
7359
7369
|
if (!isCheckLevel(level))
|
|
@@ -11423,6 +11433,7 @@ export function createDecisionServer(options) {
|
|
|
11423
11433
|
comments: store.liveDiffComments(run.id).filter(isRevisionFeedback),
|
|
11424
11434
|
pastComments: store.allDiffComments(run.id).filter(one => one.reviewerRun === null && (one.consumedBy !== null || one.supersededBy !== null)),
|
|
11425
11435
|
reviewerFindings: store.allDiffComments(run.id).filter(one => one.reviewerRun !== null),
|
|
11436
|
+
automaticReview: buildReviewOf(store, run.id),
|
|
11426
11437
|
revisions: store.revisionsFromRun(run.id).flatMap(one => {
|
|
11427
11438
|
const child = store.getTask(one.id);
|
|
11428
11439
|
if (child === null || !visible(store.lookupRef(one.id)?.repo ?? null))
|
|
@@ -22115,6 +22126,16 @@ function resultPanelParts(detail, o) {
|
|
|
22115
22126
|
if (current == null)
|
|
22116
22127
|
attention.push(...receipt.caveats.filter(caveat => !historicalAssessmentReason(caveat) && !attention.some(problem => problem.includes(caveat))));
|
|
22117
22128
|
attention.push(...(handoff?.followUps ?? []).map(one => `Follow-up: ${one}`));
|
|
22129
|
+
// The automatic review: a HIGH that did not send the work back, or a review
|
|
22130
|
+
// that could not finish, is the person's to see. (Said once when the
|
|
22131
|
+
// current assignment already carries it.)
|
|
22132
|
+
const automatic = detail.automaticReview ?? null;
|
|
22133
|
+
if (automatic !== null && automatic.sentBackAs === null) {
|
|
22134
|
+
const reviewed = [...(automatic.state === "not-reviewed" ? [`Not reviewed: ${automatic.reason ?? "the automatic review did not finish"}.`] : []), ...automatic.high.map(one => `Review: ${findingWords(one)}`)];
|
|
22135
|
+
for (const line of reviewed)
|
|
22136
|
+
if (!attention.includes(line))
|
|
22137
|
+
attention.push(line);
|
|
22138
|
+
}
|
|
22118
22139
|
// Publication risks stay in the open (repair 2026-09-14); the routine
|
|
22119
22140
|
// publication fact lives with the build details below.
|
|
22120
22141
|
// With the shared status, the pull request is its own quiet row (its reason under Details).
|
|
@@ -22189,6 +22210,10 @@ function resultPanelParts(detail, o) {
|
|
|
22189
22210
|
];
|
|
22190
22211
|
if (agentAccount.length > 0)
|
|
22191
22212
|
summaryParts.push(`<details class="result-notes" data-result-notes-agent><summary>What the agent reported</summary>${agentAccount.join("")}</details>`);
|
|
22213
|
+
// MEDIUM and LOW never block: suggested follow-ups, on request.
|
|
22214
|
+
if (automatic !== null && automatic.followUps.length > 0) {
|
|
22215
|
+
summaryParts.push(`<details class="result-notes" data-review-followups="${automatic.followUps.length}"><summary>Suggested follow-ups · ${automatic.followUps.length}</summary><ul class="result-changes">${automatic.followUps.map(one => `<li><span class="badge">${one.severity === "MEDIUM" ? "Medium" : "Low"}</span> <span class="mono">${escape(`${one.file}:${one.line}`)}</span> ${escape(one.scenario)}</li>`).join("")}</ul></details>`);
|
|
22216
|
+
}
|
|
22192
22217
|
const evidenceSources = [
|
|
22193
22218
|
...(report !== null ? [report.ok ? `verified report${report.truncated ? " (shortened)" : ""}` : "report (unverifiable)"] : []),
|
|
22194
22219
|
...(patch !== null ? [patchOk ? `sealed diff${patch.truncated ? " (shortened)" : ""}` : "diff (unverifiable)"] : []),
|
package/dist/store.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { type ProjectAccess } from "./project-access.js";
|
|
|
15
15
|
import { type LedgerEntry } from "./action-ledger.js";
|
|
16
16
|
import type { LimitReading, LimitWindow } from "./provider-limits.js";
|
|
17
17
|
import { underCeiling, type OrgPolicy, type SavedPolicy } from "./policy.js";
|
|
18
|
+
import { type ReviewSwitch } from "./review-switch.js";
|
|
18
19
|
import { type Billing, type Budget, type BudgetAgent, type BudgetHold, type BudgetScope, type BudgetState, type SpendItem } from "./spend.js";
|
|
19
20
|
import { type RetentionKind, type RetentionPeriods } from "./retention.js";
|
|
20
21
|
import { type CheckoutCleanup } from "./storage.js";
|
|
@@ -2419,6 +2420,14 @@ export declare class Store {
|
|
|
2419
2420
|
};
|
|
2420
2421
|
/** v99: a policy change, as the ledger keeps it: what changed and from what to what. Nothing is kept when nothing changed. */
|
|
2421
2422
|
private recordPolicy;
|
|
2423
|
+
/** The project's automatic review switch: its explicit setting, else on
|
|
2424
|
+
* while a hands-off mode is active on it, else off. */
|
|
2425
|
+
reviewSwitch(repo: string, now: Date): ReviewSwitch;
|
|
2426
|
+
/** Turn a project's automatic review on or off; the policy log keeps before → after. */
|
|
2427
|
+
setReviewSwitch(repo: string, on: boolean, by: string, now: Date): {
|
|
2428
|
+
before: ReviewSwitch;
|
|
2429
|
+
after: ReviewSwitch;
|
|
2430
|
+
};
|
|
2422
2431
|
/** A project deleted (project-delete.ts): who, and what it held → how it went. Returns the entry's id. */
|
|
2423
2432
|
recordProjectDeleted(by: string, repo: string, held: string, how: string, now: Date): number;
|
|
2424
2433
|
recordAction(entry: Omit<LedgerEntry, "id" | "detail"> & {
|
|
@@ -5101,8 +5110,10 @@ export declare class Store {
|
|
|
5101
5110
|
hardStop: boolean;
|
|
5102
5111
|
}, by: string, now: Date): Budget;
|
|
5103
5112
|
removeBudget(id: number, by: string, now: Date): boolean;
|
|
5104
|
-
/** How long each kind of data is kept; null is forever (
|
|
5113
|
+
/** How long each kind of data is kept; null is forever. A kind nobody chose keeps its default (evidence 28 days, the rest forever). */
|
|
5105
5114
|
retentionPeriods(): RetentionPeriods;
|
|
5115
|
+
/** The periods someone chose: only kinds with a saved row. */
|
|
5116
|
+
retentionChosen(): Partial<RetentionPeriods>;
|
|
5106
5117
|
/** Set how long one kind is kept (null: forever); the ledger keeps before → after. */
|
|
5107
5118
|
setRetentionPeriod(kind: RetentionKind, days: number | null, by: string, now: Date): RetentionPeriods;
|
|
5108
5119
|
/** Settings → Storage: when a finished task's clean checkout is removed (the default until someone chooses). */
|
|
@@ -7078,6 +7089,30 @@ export declare class Store {
|
|
|
7078
7089
|
group: boolean;
|
|
7079
7090
|
}): number;
|
|
7080
7091
|
finishUnspawnedProcess(witness: number, now: Date): void;
|
|
7092
|
+
/** The reconcile road for a pid-less witness a crash or a thrown spawn
|
|
7093
|
+
* left behind: once its run has finished, no live child is tracked for it
|
|
7094
|
+
* here, every other witness of the run has exited and none of the run's
|
|
7095
|
+
* process groups is alive, it settles as never started, with a ledger
|
|
7096
|
+
* entry. A sole pid-less witness has no group to prove gone and stays for
|
|
7097
|
+
* `run settle`. Native witnesses keep their OS-only road. */
|
|
7098
|
+
settleUnspawnedWitnesses(now: Date): number;
|
|
7099
|
+
/** `run settle` (an approver's last resort): every witness of a finished
|
|
7100
|
+
* run that cannot be proven either way is recorded settled under the
|
|
7101
|
+
* approver's reason, in the ledger. Refuses while anything of the run is
|
|
7102
|
+
* alive or could be checked only from another host. Never signals. */
|
|
7103
|
+
settleRunWitnessesByApprover(args: {
|
|
7104
|
+
runId: number;
|
|
7105
|
+
by: string;
|
|
7106
|
+
why: string;
|
|
7107
|
+
}, now: Date): {
|
|
7108
|
+
ok: true;
|
|
7109
|
+
witnesses: number[];
|
|
7110
|
+
repeated: boolean;
|
|
7111
|
+
} | {
|
|
7112
|
+
ok: false;
|
|
7113
|
+
reason: "unknown-run" | "still-running" | "alive";
|
|
7114
|
+
detail: string;
|
|
7115
|
+
};
|
|
7081
7116
|
recordRunProcess(runId: number, pid: number, now: Date, group?: boolean, witness?: number): void;
|
|
7082
7117
|
/** The native OS object a witnessed spawn ran inside (v53) — recorded
|
|
7083
7118
|
* with the spawn, before the target can execute. */
|