toolroll 0.9.92 → 0.9.94
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 +87 -0
- package/dist/activity-line.d.ts +7 -4
- package/dist/activity-line.js +9 -7
- package/dist/agent-fence.d.ts +4 -0
- package/dist/agent-fence.js +4 -1
- package/dist/approval-authority.d.ts +7 -1
- package/dist/approval-authority.js +11 -2
- package/dist/approval-password.d.ts +31 -0
- package/dist/approval-password.js +26 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +3 -3
- package/dist/assignment.js +16 -3
- package/dist/boot-identity.d.ts +2 -1
- package/dist/boot-identity.js +6 -4
- package/dist/bounded-spawn.d.ts +57 -0
- package/dist/bounded-spawn.js +220 -0
- package/dist/browser/terminal.js +42 -42
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +26 -26
- package/dist/browser-crew.js +2 -2
- package/dist/browser-shell.d.ts +1 -1
- package/dist/browser-shell.js +2 -2
- package/dist/browser-workspace.d.ts +48 -7
- package/dist/browser-workspace.js +3 -3
- package/dist/builder-restart.d.ts +21 -0
- package/dist/builder-restart.js +50 -0
- package/dist/builder.d.ts +4 -0
- package/dist/builder.js +73 -25
- package/dist/chat-acceptance.js +1 -1
- package/dist/chat-actions.d.ts +12 -2
- package/dist/chat-actions.js +25 -2
- package/dist/chat-channel.js +11 -0
- package/dist/chat-decide.d.ts +7 -2
- package/dist/chat-decide.js +37 -9
- package/dist/chat-quiet.js +3 -3
- package/dist/chat-voice.d.ts +1 -1
- package/dist/chat-voice.js +2 -2
- package/dist/check-facts.d.ts +126 -0
- package/dist/check-facts.js +318 -0
- package/dist/check-failure.d.ts +22 -3
- package/dist/check-failure.js +57 -13
- package/dist/check-levels-ui.d.ts +4 -0
- package/dist/check-levels-ui.js +10 -1
- package/dist/check-load.d.ts +85 -0
- package/dist/check-load.js +147 -0
- package/dist/check-only-standing.d.ts +36 -0
- package/dist/check-only-standing.js +39 -0
- package/dist/check-only.d.ts +15 -0
- package/dist/check-only.js +99 -0
- package/dist/check-report.d.ts +7 -0
- package/dist/check-report.js +4 -3
- package/dist/check-stamp.d.ts +46 -0
- package/dist/check-stamp.js +93 -0
- package/dist/checkout-cleanup.d.ts +36 -5
- package/dist/checkout-cleanup.js +166 -14
- package/dist/cli-help.d.ts +4 -1
- package/dist/cli-help.js +47 -30
- package/dist/cli.js +16 -1
- package/dist/code-extract.d.ts +44 -0
- package/dist/code-extract.js +88 -0
- package/dist/code-map-acts.d.ts +24 -0
- package/dist/code-map-acts.js +53 -0
- package/dist/code-map-admission.d.ts +52 -0
- package/dist/code-map-admission.js +295 -0
- package/dist/code-map-build.d.ts +63 -0
- package/dist/code-map-build.js +415 -0
- package/dist/code-map-cli.d.ts +153 -0
- package/dist/code-map-cli.js +103 -0
- package/dist/code-map-main.d.ts +1 -0
- package/dist/code-map-main.js +48 -0
- package/dist/code-map-read.d.ts +106 -0
- package/dist/code-map-read.js +171 -0
- package/dist/code-map-report.d.ts +37 -0
- package/dist/code-map-report.js +95 -0
- package/dist/code-map-shape.d.ts +172 -0
- package/dist/code-map-shape.js +327 -0
- package/dist/code-map-store.d.ts +136 -0
- package/dist/code-map-store.js +233 -0
- package/dist/coding-provider.d.ts +2 -0
- package/dist/coding-provider.js +3 -2
- package/dist/coding-update.js +3 -2
- package/dist/coding-workspace.js +4 -4
- package/dist/console-chrome.js +2 -2
- package/dist/containment.d.ts +5 -0
- package/dist/containment.js +14 -1
- package/dist/contracts/chat-actions.d.ts +9 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +52 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +12 -5
- package/dist/contracts/console-api.js +12 -5
- package/dist/contracts/gateway-tools.d.ts +17 -0
- package/dist/contracts/gateway-tools.js +3 -1
- package/dist/contracts/lead-tools.d.ts +61 -0
- package/dist/contracts/lead-tools.js +10 -1
- package/dist/contracts/project-knowledge.d.ts +38 -5
- package/dist/contracts/project-knowledge.js +11 -5
- package/dist/contracts/project-memory.d.ts +3 -1
- package/dist/contracts/project-memory.js +3 -2
- package/dist/contracts/release-check.d.ts +25 -1
- package/dist/contracts/release-check.js +19 -0
- package/dist/contracts/storage.d.ts +14 -0
- package/dist/contracts/storage.js +3 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/contracts/youiyoux-request.d.ts +1 -1
- package/dist/contracts/youiyoux-request.js +1 -1
- package/dist/desktop-access.js +6 -2
- package/dist/desktop-update.d.ts +2 -2
- package/dist/desktop-update.js +13 -9
- package/dist/dispatch.js +18 -15
- package/dist/dispose.js +8 -2
- package/dist/easy-start.js +2 -3
- package/dist/eval-hooks.d.ts +55 -0
- package/dist/eval-hooks.js +227 -4
- package/dist/evals.js +2 -1
- package/dist/fast-start.d.ts +136 -0
- package/dist/fast-start.js +431 -0
- package/dist/fast-terms.d.ts +9 -0
- package/dist/fast-terms.js +23 -0
- package/dist/fenced-check-coverage.d.ts +62 -0
- package/dist/fenced-check-coverage.js +54 -0
- package/dist/first-run.js +1 -1
- package/dist/flaky-ci.d.ts +31 -0
- package/dist/flaky-ci.js +149 -0
- package/dist/flaky-cli.d.ts +60 -0
- package/dist/flaky-cli.js +60 -0
- package/dist/flaky.d.ts +83 -0
- package/dist/flaky.js +264 -0
- package/dist/flow-card-task.js +3 -0
- package/dist/flow-triggers.d.ts +1 -1
- package/dist/flow-triggers.js +1 -1
- package/dist/github-import.d.ts +62 -0
- package/dist/github-import.js +146 -0
- package/dist/guides.js +15 -10
- package/dist/invoke.d.ts +5 -1
- package/dist/invoke.js +49 -14
- package/dist/job-object-helper.ps1 +10 -3
- package/dist/keymap.d.ts +41 -0
- package/dist/keymap.js +51 -0
- package/dist/knowledge-cli.js +1 -1
- package/dist/knowledge-ui.d.ts +21 -4
- package/dist/knowledge-ui.js +75 -7
- package/dist/laptop-background.d.ts +16 -0
- package/dist/laptop-background.js +108 -2
- package/dist/lead-context.js +1 -1
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-progress.js +3 -0
- package/dist/lead-status.d.ts +2 -0
- package/dist/lead-status.js +6 -2
- package/dist/lead-tools.js +51 -2
- package/dist/lead.js +1 -1
- package/dist/lesson-notes.d.ts +81 -0
- package/dist/lesson-notes.js +336 -0
- package/dist/limits-ui.js +1 -1
- package/dist/local-owner.d.ts +1 -1
- package/dist/local-owner.js +11 -0
- package/dist/machine-check.d.ts +16 -0
- package/dist/machine-check.js +26 -0
- package/dist/machine-identity.d.ts +75 -0
- package/dist/machine-identity.js +170 -0
- package/dist/mcp-core.js +11 -1
- package/dist/memory-cli.d.ts +300 -1
- package/dist/memory-cli.js +62 -9
- package/dist/memory-pass.d.ts +16 -0
- package/dist/memory-pass.js +67 -3
- package/dist/memory-tidy.d.ts +181 -0
- package/dist/memory-tidy.js +523 -0
- package/dist/memory-use.d.ts +35 -0
- package/dist/memory-use.js +109 -0
- package/dist/merge-checklist.d.ts +55 -0
- package/dist/merge-checklist.js +182 -0
- package/dist/needs-you.d.ts +32 -13
- package/dist/needs-you.js +108 -49
- package/dist/nightly-github.d.ts +2 -0
- package/dist/nightly-github.js +4 -4
- package/dist/operate-remote-arguments.js +6 -1
- package/dist/operate-remote.js +3 -1
- package/dist/operate.d.ts +6 -1
- package/dist/operate.js +540 -70
- package/dist/phase-routing.js +2 -2
- package/dist/plain-schema.d.ts +23 -0
- package/dist/plain-schema.js +94 -0
- package/dist/plain-words.d.ts +20 -0
- package/dist/plain-words.js +66 -0
- package/dist/planner.js +2 -1
- package/dist/process-liveness.d.ts +2 -0
- package/dist/process-liveness.js +61 -17
- package/dist/project-delete-ui.d.ts +7 -2
- package/dist/project-delete-ui.js +12 -1
- package/dist/project-delete.js +13 -2
- package/dist/project-knowledge.d.ts +18 -11
- package/dist/project-knowledge.js +39 -9
- package/dist/project-learning.d.ts +2 -1
- package/dist/project-learning.js +15 -45
- package/dist/project-memory.d.ts +5 -0
- package/dist/project-memory.js +22 -2
- package/dist/proof.js +30 -5
- package/dist/provider-auth.js +1 -1
- package/dist/publish.js +9 -1
- package/dist/pull-request-flow.js +5 -0
- package/dist/push.d.ts +26 -3
- package/dist/push.js +55 -15
- package/dist/release-adapters.d.ts +9 -0
- package/dist/release-adapters.js +87 -1
- package/dist/release-approval.d.ts +191 -0
- package/dist/release-approval.js +382 -0
- package/dist/release-auto.js +26 -17
- package/dist/release-card.d.ts +74 -6
- package/dist/release-card.js +179 -23
- package/dist/release-carry.d.ts +7 -2
- package/dist/release-carry.js +39 -9
- package/dist/release-cli.d.ts +4 -0
- package/dist/release-cli.js +38 -23
- package/dist/release-coverage.js +1 -1
- package/dist/release-next.d.ts +48 -0
- package/dist/release-next.js +161 -0
- package/dist/release.d.ts +50 -1
- package/dist/release.js +118 -11
- package/dist/remote-destination.d.ts +36 -0
- package/dist/remote-destination.js +217 -0
- package/dist/remote-main.d.ts +48 -0
- package/dist/remote-main.js +84 -5
- package/dist/repository-context.d.ts +3 -2
- package/dist/repository-context.js +112 -70
- package/dist/restart-certification.d.ts +1 -0
- package/dist/restart-certification.js +8 -6
- package/dist/result-acts.d.ts +29 -21
- package/dist/result-acts.js +36 -25
- package/dist/result-follow-ups.d.ts +2 -2
- package/dist/result-follow-ups.js +18 -4
- package/dist/result-review.d.ts +1 -1
- package/dist/route-change-check.d.ts +4 -0
- package/dist/route-change-check.js +13 -0
- package/dist/route-change.d.ts +22 -0
- package/dist/route-change.js +89 -0
- package/dist/route-hooks.js +3 -2
- package/dist/route-results.d.ts +6 -1
- package/dist/route-results.js +14 -5
- package/dist/run-closeout.d.ts +1 -1
- package/dist/run-closeout.js +7 -5
- package/dist/runner-process.js +3 -2
- package/dist/runner.js +2 -1
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +22 -9
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +24 -18
- package/dist/server/chat.js +14 -12
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +26 -35
- package/dist/server/console.css +23 -0
- package/dist/server/guards.js +4 -0
- package/dist/server/home.d.ts +6 -0
- package/dist/server/home.js +21 -13
- package/dist/server/http.js +1 -1
- package/dist/server/kit.js +1 -1
- package/dist/server/merge-ready-view.d.ts +10 -0
- package/dist/server/merge-ready-view.js +30 -0
- package/dist/server/pages-assets.d.ts +1 -0
- package/dist/server/pages-assets.js +8 -5
- package/dist/server/pages.js +103 -82
- package/dist/server/render-chat.js +2 -2
- package/dist/server/render-code-map.d.ts +30 -0
- package/dist/server/render-code-map.js +137 -0
- package/dist/server/render-flaky.d.ts +20 -0
- package/dist/server/render-flaky.js +59 -0
- package/dist/server/render-history.js +1 -1
- package/dist/server/render-new-task.d.ts +26 -1
- package/dist/server/render-new-task.js +30 -5
- package/dist/server/render-pages.d.ts +1 -175
- package/dist/server/render-pages.js +38 -313
- package/dist/server/render-projects.d.ts +2 -1
- package/dist/server/render-projects.js +9 -7
- package/dist/server/render-release.d.ts +19 -0
- package/dist/server/render-release.js +84 -0
- package/dist/server/render-results.d.ts +2 -2
- package/dist/server/render-results.js +71 -73
- package/dist/server/render-tasks.d.ts +13 -2
- package/dist/server/render-tasks.js +156 -139
- package/dist/server/retired-routes.d.ts +17 -0
- package/dist/server/retired-routes.js +53 -0
- package/dist/server/route-table.d.ts +4 -1
- package/dist/server/route-table.js +52 -13
- package/dist/server/settings.js +129 -22
- package/dist/server/task-views.js +61 -26
- package/dist/server/tasks.d.ts +17 -6
- package/dist/server/tasks.js +273 -338
- package/dist/server/welcome.js +1 -1
- package/dist/settings-groups-ui.js +1 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/stop-proof.d.ts +67 -0
- package/dist/stop-proof.js +295 -0
- package/dist/storage-sweep-process.js +1 -1
- package/dist/storage-sweep.d.ts +5 -3
- package/dist/storage-sweep.js +30 -9
- package/dist/storage-ui.js +28 -17
- package/dist/storage.d.ts +27 -2
- package/dist/storage.js +39 -2
- package/dist/store.d.ts +148 -34
- package/dist/store.js +626 -177
- package/dist/surface.js +46 -5
- package/dist/sweep-quarantine.d.ts +1 -0
- package/dist/sweep-quarantine.js +6 -4
- package/dist/tamper-scan.d.ts +4 -0
- package/dist/tamper-scan.js +90 -0
- package/dist/task-aliases.d.ts +5 -0
- package/dist/task-aliases.js +9 -0
- package/dist/task-control.js +4 -1
- package/dist/task-outcome-cli.d.ts +2 -0
- package/dist/task-outcome-cli.js +36 -17
- package/dist/task-sizing.d.ts +5 -0
- package/dist/task-sizing.js +2 -0
- package/dist/task-source.d.ts +7 -0
- package/dist/task-source.js +14 -0
- package/dist/task-status.d.ts +9 -0
- package/dist/task-status.js +40 -49
- package/dist/teardown-git.d.ts +16 -0
- package/dist/teardown-git.js +63 -5
- package/dist/telegram-progress.js +5 -3
- package/dist/telegram-status.js +6 -4
- package/dist/test-infra.d.ts +8 -0
- package/dist/test-infra.js +18 -0
- package/dist/tidy.js +1 -1
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +18 -5
- package/dist/tui-contract.d.ts +24 -0
- package/dist/tui-source.js +67 -8
- package/dist/tui.js +51 -51
- package/dist/vanished-retry.d.ts +30 -0
- package/dist/vanished-retry.js +174 -0
- package/dist/work-index.d.ts +2 -0
- package/dist/work-index.js +82 -70
- package/dist/work-summary.d.ts +2 -2
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +28 -22
- package/dist/worktree.d.ts +12 -3
- package/dist/worktree.js +68 -13
- package/package.json +1 -1
- package/dist/server/render-next.d.ts +0 -63
- package/dist/server/render-next.js +0 -109
package/dist/flaky.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixed outcomes under recorded conditions (docs/plans/learning-loops.md, "Flaky tests"): advisory only. Nothing here
|
|
3
|
+
* changes a check's outcome, a verdict, an approval or a rerun; rerunning stays a person's `task regate`.
|
|
4
|
+
*
|
|
5
|
+
* Observations are written here, in the transaction that records a check's results (recordObservations). Each adds a
|
|
6
|
+
* tracked identity (`learning_watch`, reason `observation`): every Full check with a handshake asks about it for 90
|
|
7
|
+
* days (check-facts.ts learnRequestFor). Lesson notes (lesson-notes.ts) add their own. This module and lesson-notes.ts
|
|
8
|
+
* are the only writers of `learning_watch`, and this one of `local_observation`; release-auto's learning scan
|
|
9
|
+
* (eval-hooks.ts learningWriteProblem) needs a person for any other. A policy path (release-auto.ts EVAL_POLICY).
|
|
10
|
+
*
|
|
11
|
+
* The list reads only. An infrastructure-class run is never an observation: a run's class is stamped after its check,
|
|
12
|
+
* so recording skips the runs already stamped and every read skips the rest.
|
|
13
|
+
*/
|
|
14
|
+
import { projectGitDir } from "./check-report.js";
|
|
15
|
+
import { verifiedBlobs } from "./commit-extract.js";
|
|
16
|
+
import { ciNotPaired, ciRepositoriesOf } from "./flaky-ci.js";
|
|
17
|
+
/** Track an identity a local observation named (one row per observation; first write wins, never rewritten). */
|
|
18
|
+
export function watchObservation(store, input) {
|
|
19
|
+
if (input.repo.trim() === "" || input.identity.length < 1 || input.identity.length > 2600 || !Number.isSafeInteger(input.observation) || input.observation < 1) {
|
|
20
|
+
throw new Error("a tracked identity needs its project, a bounded identity and the observation that named it");
|
|
21
|
+
}
|
|
22
|
+
const added = store.handle.prepare(`INSERT INTO learning_watch (repo, identity, reason, source_ref, since, until) VALUES (?, ?, 'observation', ?, ?, NULL)
|
|
23
|
+
ON CONFLICT (repo, identity, reason, source_ref) DO NOTHING`).run(input.repo, input.identity, `observation:${input.observation}`, input.now.toISOString());
|
|
24
|
+
return Number(added.changes) === 1;
|
|
25
|
+
}
|
|
26
|
+
// ---- local observations --------------------------------------------------------------------------------------------
|
|
27
|
+
/** How far back the list looks, how long the pruned rows are kept, and how many runs clear a row (the plan's defaults). */
|
|
28
|
+
export const FLAKY_WINDOW_DAYS = 90;
|
|
29
|
+
export const FLAKY_RETAIN_DAYS = 180;
|
|
30
|
+
export const FLAKY_CLEAR_RUNS = 20;
|
|
31
|
+
const DAY_MS = 86_400_000;
|
|
32
|
+
/** Twin checks one settled check is compared with, observations it writes, and rows the list and a history read. */
|
|
33
|
+
const TWINS_MAX = 50;
|
|
34
|
+
const OBSERVED_MAX = 500;
|
|
35
|
+
const ROWS_MAX = 200;
|
|
36
|
+
const HISTORY_MAX = 100;
|
|
37
|
+
/** Distinct trees one read of the list opens: past this, the rest of a row's status stays "mixed outcomes". */
|
|
38
|
+
const TREES_MAX = 40;
|
|
39
|
+
/** What can be observed at all: a test case, a journey or its group, or a file, never `part:`, `upgrade:`, `check` or
|
|
40
|
+
* `unexplained`, and never a real-model journey (a model's behaviour isn't a test's flakiness). */
|
|
41
|
+
export function observableIdentity(identity) {
|
|
42
|
+
if (/^(?:journey|group):[^:]*:real(?::|$)/.test(identity))
|
|
43
|
+
return false;
|
|
44
|
+
return /^(?:unit|journey|group|file):./.test(identity);
|
|
45
|
+
}
|
|
46
|
+
const fileLevel = (identity) => identity.startsWith("file:");
|
|
47
|
+
/** The explicit sources (docs/plans/learning-loops.md, "Explicit execution evidence"). */
|
|
48
|
+
const EXPLICIT = "('report','learn')";
|
|
49
|
+
/** A tree's facts as comparisons need them, or null (nothing comparable was recorded). */
|
|
50
|
+
function comparableFact(store, runId) {
|
|
51
|
+
const row = store.handle.prepare("SELECT repo, task_ref, head, tree, command_digest, access, harness_digest, conditions_digest, purpose FROM check_fact WHERE run = ?").get(runId);
|
|
52
|
+
if (row === undefined || row["repo"] == null || row["tree"] == null || row["conditions_digest"] == null)
|
|
53
|
+
return null;
|
|
54
|
+
const text = (key) => row[key] == null ? null : String(row[key]);
|
|
55
|
+
return { repo: String(row["repo"]), taskRef: Number(row["task_ref"]), head: text("head"), tree: String(row["tree"]), commandDigest: text("command_digest"), access: text("access"), harness: text("harness_digest"), digest: String(row["conditions_digest"]), purpose: String(row["purpose"]) };
|
|
56
|
+
}
|
|
57
|
+
const infrastructure = (store, runId) => store.handle.prepare("SELECT 1 FROM run WHERE id = ? AND exec_class = 'infrastructure'").get(runId) !== undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Every observation one settled check adds, in the transaction that records its results (check-facts.ts): an in-check
|
|
60
|
+
* retry, and each identity that failed in one check and explicitly passed in another with equal tree, command, access,
|
|
61
|
+
* harness and conditions. A `file:` identity is observed only as "failed, then the whole check passed", and only when
|
|
62
|
+
* no case in that file names the failure. Idempotent: racing dispositions on one pair leave one row.
|
|
63
|
+
*/
|
|
64
|
+
export function recordObservations(store, runId, now) {
|
|
65
|
+
const fact = comparableFact(store, runId);
|
|
66
|
+
if (fact === null || infrastructure(store, runId))
|
|
67
|
+
return [];
|
|
68
|
+
const at = now.toISOString();
|
|
69
|
+
const added = [];
|
|
70
|
+
const insert = store.handle.prepare(`INSERT INTO local_observation (repo, identity, failed_run, passed_run, tree, conditions_digest, kind, observed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
71
|
+
ON CONFLICT (identity, failed_run, passed_run) DO NOTHING`);
|
|
72
|
+
const observe = (identity, failed, passed, kind) => {
|
|
73
|
+
if (added.length >= OBSERVED_MAX || !observableIdentity(identity))
|
|
74
|
+
return;
|
|
75
|
+
const done = insert.run(fact.repo, identity, failed, passed, fact.tree, fact.digest, kind, at);
|
|
76
|
+
if (Number(done.changes) !== 1)
|
|
77
|
+
return;
|
|
78
|
+
const id = Number(done.lastInsertRowid);
|
|
79
|
+
added.push(id);
|
|
80
|
+
watchObservation(store, { repo: fact.repo, identity, observation: id, now });
|
|
81
|
+
};
|
|
82
|
+
// A nightly's in-run reruns are never observations; a retry inside any other check is.
|
|
83
|
+
if (fact.purpose !== "nightly") {
|
|
84
|
+
for (const row of store.handle.prepare(`SELECT DISTINCT identity FROM check_result WHERE run = ? AND state = 'passed-on-retry' AND source IN ${EXPLICIT}`).all(runId)) {
|
|
85
|
+
const identity = String(row["identity"]);
|
|
86
|
+
if (!fileLevel(identity))
|
|
87
|
+
observe(identity, runId, runId, "in-check-retry");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const twins = store.handle.prepare(`SELECT f.run, f.task_ref, f.head FROM check_fact f JOIN run r ON r.id = f.run
|
|
91
|
+
WHERE f.repo = ? AND f.tree = ? AND f.conditions_digest = ? AND f.command_digest IS ? AND f.access IS ? AND f.harness_digest IS ? AND f.run <> ?
|
|
92
|
+
AND (r.exec_class IS NULL OR r.exec_class <> 'infrastructure') ORDER BY f.run DESC LIMIT ?`).all(fact.repo, fact.tree, fact.digest, fact.commandDigest, fact.access, fact.harness, runId, TWINS_MAX);
|
|
93
|
+
const mixed = store.handle.prepare(`SELECT DISTINCT a.identity FROM check_result a JOIN check_result b ON b.identity = a.identity AND b.run = ? AND b.state = 'passed' AND b.source IN ${EXPLICIT}
|
|
94
|
+
WHERE a.run = ? AND a.state = 'failed' AND a.source IN ${EXPLICIT}`);
|
|
95
|
+
const received = (run, state) => store.handle.prepare("SELECT 1 FROM check_result WHERE run = ? AND identity = 'check' AND source = 'receipt' AND state = ?").get(run, state) !== undefined;
|
|
96
|
+
for (const twin of twins) {
|
|
97
|
+
const other = Number(twin["run"]);
|
|
98
|
+
const kind = Number(twin["task_ref"]) === fact.taskRef && (twin["head"] == null ? null : String(twin["head"])) === fact.head ? "regate" : "same-tree";
|
|
99
|
+
for (const row of mixed.all(runId, other))
|
|
100
|
+
observe(String(row["identity"]), other, runId, kind);
|
|
101
|
+
for (const row of mixed.all(other, runId))
|
|
102
|
+
observe(String(row["identity"]), runId, other, kind);
|
|
103
|
+
// File level: an earlier check failed in the file, this later one passed whole.
|
|
104
|
+
if (other < runId && received(runId, "passed") && received(other, "failed")) {
|
|
105
|
+
const failed = store.handle.prepare("SELECT identity, source FROM check_result WHERE run = ? AND state = 'failed'").all(other).map(row => String(row["identity"]));
|
|
106
|
+
for (const identity of new Set(failed.filter(fileLevel))) {
|
|
107
|
+
const prefix = `unit:${identity.slice("file:".length)}::`;
|
|
108
|
+
if (!failed.some(one => one.startsWith(prefix)))
|
|
109
|
+
observe(identity, other, runId, kind);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return added;
|
|
114
|
+
}
|
|
115
|
+
/** The storage sweep's own step: observations older than the retention window go; nothing else deletes them. */
|
|
116
|
+
export function pruneObservations(store, now) {
|
|
117
|
+
const cut = new Date(now.getTime() - FLAKY_RETAIN_DAYS * DAY_MS).toISOString();
|
|
118
|
+
return Number(store.handle.prepare("DELETE FROM local_observation WHERE observed_at < ?").run(cut).changes);
|
|
119
|
+
}
|
|
120
|
+
/** The test file an identity names, when it names one. */
|
|
121
|
+
export function identityFile(identity) {
|
|
122
|
+
const unit = /^unit:(.+?)::/.exec(identity);
|
|
123
|
+
if (unit !== null)
|
|
124
|
+
return unit[1];
|
|
125
|
+
return identity.startsWith("file:") ? identity.slice("file:".length) : null;
|
|
126
|
+
}
|
|
127
|
+
/** A readable name: `<file> › <test>`; a journey's group and name; a file with its limit said. */
|
|
128
|
+
export function identityWords(identity) {
|
|
129
|
+
const unit = /^unit:(.+?)::(.*)$/s.exec(identity);
|
|
130
|
+
if (unit !== null)
|
|
131
|
+
return `${unit[1]} › ${unit[2]}`;
|
|
132
|
+
const journey = /^journey:([^:]*):([^:]*):(.+?)::(.*)$/s.exec(identity);
|
|
133
|
+
if (journey !== null)
|
|
134
|
+
return `${journey[1]} journey ${journey[3]} › ${journey[4]}`;
|
|
135
|
+
const group = /^group:([^:]*):([^:]*):(.*)$/s.exec(identity);
|
|
136
|
+
if (group !== null)
|
|
137
|
+
return `${group[1]} journeys ${group[3]}`;
|
|
138
|
+
return fileLevel(identity) ? `${identity.slice("file:".length)} (file-level, not confirmed per test)` : identity;
|
|
139
|
+
}
|
|
140
|
+
const SOURCE_WORDS = { report: "Test report", learn: "Test report", log: "Check log", receipt: "Check result" };
|
|
141
|
+
/** Whether results older than the kept window were pruned for this project. */
|
|
142
|
+
function prunedBefore(store, repo, now) {
|
|
143
|
+
const cut = new Date(now.getTime() - FLAKY_RETAIN_DAYS * DAY_MS).toISOString();
|
|
144
|
+
return store.handle.prepare("SELECT 1 FROM check_fact WHERE repo = ? AND recorded_at < ? LIMIT 1").get(repo, cut) !== undefined;
|
|
145
|
+
}
|
|
146
|
+
/** Each tree's blob ids for the given files, read through verified objects, once per tree, within a budget. */
|
|
147
|
+
function blobReader(repo, files) {
|
|
148
|
+
const gitDir = projectGitDir(repo);
|
|
149
|
+
const seen = new Map();
|
|
150
|
+
return (tree, file) => {
|
|
151
|
+
if (gitDir === null)
|
|
152
|
+
return undefined;
|
|
153
|
+
let read = seen.get(tree);
|
|
154
|
+
if (read === undefined) {
|
|
155
|
+
if (seen.size >= TREES_MAX)
|
|
156
|
+
return undefined;
|
|
157
|
+
try {
|
|
158
|
+
const blobs = verifiedBlobs(gitDir, tree, files);
|
|
159
|
+
read = blobs.ok ? new Map([...blobs.value].map(([path, one]) => [path, one.id])) : null;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
read = null;
|
|
163
|
+
}
|
|
164
|
+
seen.set(tree, read);
|
|
165
|
+
}
|
|
166
|
+
return read === null ? undefined : read.get(file) ?? null;
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** What a row's recorded runs say since its file changed. `undefined` from the reader means a tree couldn't be read:
|
|
170
|
+
* the walk stops there and claims nothing. */
|
|
171
|
+
function statusOf(store, repo, identity, blobAt, pruned) {
|
|
172
|
+
const file = identityFile(identity);
|
|
173
|
+
if (file === null || fileLevel(identity))
|
|
174
|
+
return { status: "mixed outcomes", fileChanged: null };
|
|
175
|
+
const rows = store.handle.prepare(`SELECT f.run, f.tree, f.recorded_at, MAX(CASE WHEN r.state IN ('failed','passed-on-retry') THEN 1 ELSE 0 END) AS failed, MAX(CASE WHEN r.state = 'passed' THEN 1 ELSE 0 END) AS passed
|
|
176
|
+
FROM check_result r JOIN check_fact f ON f.run = r.run WHERE f.repo = ? AND r.identity = ? AND r.source IN ${EXPLICIT} AND f.tree IS NOT NULL GROUP BY f.run ORDER BY f.run DESC LIMIT 200`).all(repo, identity);
|
|
177
|
+
if (rows.length === 0)
|
|
178
|
+
return { status: "mixed outcomes", fileChanged: null };
|
|
179
|
+
const newest = blobAt(String(rows[0]["tree"]), file);
|
|
180
|
+
if (newest === undefined)
|
|
181
|
+
return { status: "mixed outcomes", fileChanged: null };
|
|
182
|
+
let clean = 0, boundary = false, failedSince = false, earliest = null;
|
|
183
|
+
for (const row of rows) {
|
|
184
|
+
const here = blobAt(String(row["tree"]), file);
|
|
185
|
+
if (here === undefined)
|
|
186
|
+
return { status: "mixed outcomes", fileChanged: null };
|
|
187
|
+
if (here !== newest) {
|
|
188
|
+
boundary = true;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
earliest = String(row["recorded_at"]);
|
|
192
|
+
if (Number(row["failed"]) === 1) {
|
|
193
|
+
failedSince = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (Number(row["passed"]) === 1)
|
|
197
|
+
clean++;
|
|
198
|
+
}
|
|
199
|
+
const fileChanged = boundary ? earliest : null;
|
|
200
|
+
if (failedSince)
|
|
201
|
+
return { status: "mixed outcomes", fileChanged };
|
|
202
|
+
if (clean >= FLAKY_CLEAR_RUNS)
|
|
203
|
+
return { status: "no failures since its file changed", fileChanged };
|
|
204
|
+
// Too few passes, and the file's change is older than the results kept: there's no telling, and no zero is shown.
|
|
205
|
+
return { status: !boundary && pruned ? "not enough recorded history" : "mixed outcomes", fileChanged };
|
|
206
|
+
}
|
|
207
|
+
/** Every row for a project: reads only. `days` past the kept window is answered with `complete: false`, never a zero. */
|
|
208
|
+
export function flakyList(store, repo, now, days = FLAKY_WINDOW_DAYS) {
|
|
209
|
+
const span = Number.isSafeInteger(days) && days >= 1 && days <= 730 ? days : FLAKY_WINDOW_DAYS;
|
|
210
|
+
const since = new Date(now.getTime() - span * DAY_MS).toISOString();
|
|
211
|
+
const pruned = prunedBefore(store, repo, now);
|
|
212
|
+
const rows = store.handle.prepare(`SELECT o.identity, COUNT(DISTINCT o.tree || ':' || o.conditions_digest) AS observations, MAX(o.observed_at) AS last_seen
|
|
213
|
+
FROM local_observation o JOIN run a ON a.id = o.failed_run JOIN run b ON b.id = o.passed_run
|
|
214
|
+
WHERE o.repo = ? AND o.observed_at >= ? AND (a.exec_class IS NULL OR a.exec_class <> 'infrastructure') AND (b.exec_class IS NULL OR b.exec_class <> 'infrastructure')
|
|
215
|
+
GROUP BY o.identity ORDER BY last_seen DESC, o.identity LIMIT ?`).all(repo, since, ROWS_MAX);
|
|
216
|
+
const identities = rows.map(row => String(row["identity"]));
|
|
217
|
+
const blobAt = blobReader(repo, [...new Set(identities.flatMap(one => identityFile(one) ?? []))]);
|
|
218
|
+
const local = rows.map(row => {
|
|
219
|
+
const identity = String(row["identity"]);
|
|
220
|
+
const conditions = store.handle.prepare(`SELECT DISTINCT f.conditions_class FROM local_observation o JOIN check_fact f ON f.run = o.failed_run WHERE o.repo = ? AND o.identity = ? AND o.observed_at >= ? AND f.conditions_class IS NOT NULL ORDER BY 1`).all(repo, identity, since).map(one => String(one["conditions_class"]));
|
|
221
|
+
const sources = store.handle.prepare(`SELECT DISTINCT r.source FROM local_observation o JOIN check_result r ON r.identity = o.identity AND r.run IN (o.failed_run, o.passed_run) WHERE o.repo = ? AND o.identity = ? AND o.observed_at >= ? ORDER BY 1`).all(repo, identity, since).map(one => SOURCE_WORDS[String(one["source"])] ?? String(one["source"]));
|
|
222
|
+
const hard = store.handle.prepare(`SELECT COUNT(DISTINCT r.run) AS n FROM check_result r JOIN check_fact f ON f.run = r.run WHERE f.repo = ? AND r.identity = ? AND r.state = 'failed' AND f.recorded_at >= ? AND f.tree IS NOT NULL
|
|
223
|
+
AND NOT EXISTS (SELECT 1 FROM check_result p JOIN check_fact g ON g.run = p.run WHERE p.identity = r.identity AND p.state IN ('passed','passed-on-retry') AND g.repo = f.repo AND g.tree = f.tree AND g.conditions_digest = f.conditions_digest)`).get(repo, identity, since);
|
|
224
|
+
const read = statusOf(store, repo, identity, blobAt, pruned);
|
|
225
|
+
return { key: identity, kind: fileLevel(identity) ? "file" : "test", name: identityWords(identity), observations: Number(row["observations"]), lastSeen: String(row["last_seen"]),
|
|
226
|
+
conditions, hardFailures: Number(hard?.["n"] ?? 0), sources: [...new Set(sources)], fileChanged: read.fileChanged, status: read.status };
|
|
227
|
+
});
|
|
228
|
+
const lanes = store.handle.prepare(`SELECT lane_id, MAX(lane_name) AS lane_name, COUNT(*) AS observations, MAX(observed_at) AS last_seen FROM ci_flaky_observation WHERE repo = ? AND observed_at >= ? GROUP BY lane_id ORDER BY last_seen DESC, lane_id LIMIT ?`).all(repo, since, ROWS_MAX);
|
|
229
|
+
const ci = lanes.map(row => ({ key: `github:${String(row["lane_id"])}`, kind: "lane", name: `${String(row["lane_name"])} (GitHub CI)`, observations: Number(row["observations"]), lastSeen: String(row["last_seen"]),
|
|
230
|
+
conditions: ["GitHub CI"], hardFailures: 0, sources: ["GitHub"], fileChanged: null, status: "mixed outcomes" }));
|
|
231
|
+
const checks = store.handle.prepare("SELECT COUNT(*) AS n, SUM(CASE WHEN EXISTS (SELECT 1 FROM check_result r WHERE r.run = f.run AND r.source IN ('report','learn')) THEN 1 ELSE 0 END) AS reported FROM check_fact f WHERE f.repo = ? AND f.recorded_at >= ?").get(repo, since);
|
|
232
|
+
return { repo, days: span, rows: [...local, ...ci].sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || a.key.localeCompare(b.key)),
|
|
233
|
+
complete: !(span > FLAKY_RETAIN_DAYS && pruned), notPaired: ciNotPaired(store, ciRepositoriesOf(store, repo), since), reported: Number(checks?.["reported"] ?? 0), unreported: Number(checks?.["n"] ?? 0) - Number(checks?.["reported"] ?? 0) };
|
|
234
|
+
}
|
|
235
|
+
/** How many rows the list has now, cheaply: no tree is read. */
|
|
236
|
+
export function flakyCount(store, repo, now) {
|
|
237
|
+
const since = new Date(now.getTime() - FLAKY_WINDOW_DAYS * DAY_MS).toISOString();
|
|
238
|
+
return Number(store.handle.prepare("SELECT (SELECT COUNT(DISTINCT identity) FROM local_observation WHERE repo = ? AND observed_at >= ?) + (SELECT COUNT(DISTINCT lane_id) FROM ci_flaky_observation WHERE repo = ? AND observed_at >= ?) AS n").get(repo, since, repo, since)?.["n"] ?? 0);
|
|
239
|
+
}
|
|
240
|
+
/** One row's history, newest first: each observation with both checks (or the CI run and attempts). Null: no such row. */
|
|
241
|
+
export function flakyShow(store, repo, key, now, days = FLAKY_RETAIN_DAYS) {
|
|
242
|
+
const row = flakyList(store, repo, now, Math.max(days, FLAKY_WINDOW_DAYS)).rows.find(one => one.key === key);
|
|
243
|
+
if (row === undefined)
|
|
244
|
+
return null;
|
|
245
|
+
const history = row.kind === "lane"
|
|
246
|
+
? store.handle.prepare(`SELECT workflow_run_id, head_sha, failed_attempt, failed_conclusion, passed_attempt, passed_conclusion, observed_at FROM ci_flaky_observation WHERE repo = ? AND lane_id = ? ORDER BY observed_at DESC, id DESC LIMIT ?`)
|
|
247
|
+
.all(repo, key.slice("github:".length), HISTORY_MAX).map(one => ({ kind: "github", at: String(one["observed_at"]), failed: `GitHub #${String(one["workflow_run_id"])} try ${String(one["failed_attempt"])}`,
|
|
248
|
+
passed: `try ${String(one["passed_attempt"])}`, commit: String(one["head_sha"]).slice(0, 7), conditions: "GitHub CI" }))
|
|
249
|
+
: store.handle.prepare(`SELECT o.kind, o.failed_run, o.passed_run, o.observed_at, f.head, f.conditions_class FROM local_observation o LEFT JOIN check_fact f ON f.run = o.failed_run WHERE o.repo = ? AND o.identity = ? ORDER BY o.observed_at DESC, o.id DESC LIMIT ?`)
|
|
250
|
+
.all(repo, key, HISTORY_MAX).map(one => ({ kind: String(one["kind"]), at: String(one["observed_at"]), failed: `build #${String(one["failed_run"])}`, passed: `build #${String(one["passed_run"])}`,
|
|
251
|
+
commit: one["head"] == null ? null : String(one["head"]).slice(0, 7), conditions: one["conditions_class"] == null ? "not recorded" : String(one["conditions_class"]) }));
|
|
252
|
+
return { row, history, fix: fixTaskOf(row) };
|
|
253
|
+
}
|
|
254
|
+
/** The ordinary task "Make a task to fix it" files; a person approves it as usual. Never to skip or delete the test. */
|
|
255
|
+
export function fixTaskOf(row) {
|
|
256
|
+
// eslint-disable-next-line no-control-regex
|
|
257
|
+
const name = row.name.replace(/[\u0000-\u001f\u007f]+/g, " ").slice(0, 300);
|
|
258
|
+
const short = name.length > 72 ? `${name.slice(0, 71)}…` : name;
|
|
259
|
+
return { title: `Make this test reliable: ${short}`, goal: [
|
|
260
|
+
`Toolroll recorded mixed outcomes under recorded conditions for ${name}: ${row.observations} time${row.observations === 1 ? "" : "s"} it both failed and passed with the same code, command and recorded environment (${row.conditions.join(", ") || "not recorded"}).`,
|
|
261
|
+
row.kind === "lane" ? "It is a GitHub CI job, so the failing test isn't named: read that job's log to find it." : "",
|
|
262
|
+
"Find out why and make the test reliable. Never skip, delete or loosen the test, and don't add retries to hide it.",
|
|
263
|
+
].filter(Boolean).join(" ") };
|
|
264
|
+
}
|
package/dist/flow-card-task.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** The task behind an agent zone, read through the same status as its task page.
|
|
2
2
|
* Only saved milestone progress belongs on the card; tool calls and logs do not. */
|
|
3
|
+
import { machineChecked } from "./machine-check.js";
|
|
3
4
|
import { assignmentOf } from "./assignment.js";
|
|
4
5
|
import { scanForSecrets } from "./evidence.js";
|
|
5
6
|
import { scrubIntegrationText } from "./integrations.js";
|
|
@@ -32,6 +33,8 @@ export function storedTaskStatus(store, taskId, repo, now, root) {
|
|
|
32
33
|
return null;
|
|
33
34
|
const live = work.liveRunId === null ? null : store.getRun(work.liveRunId);
|
|
34
35
|
const facts = assignmentStatusFacts(assignment, { work: work.status, planning: live?.role === "planner" });
|
|
36
|
+
if (machineChecked(store, assignment.activeTaskId))
|
|
37
|
+
facts.machine = true;
|
|
35
38
|
// The canvas reads no evidence files. Keep the machine's recorded check visible
|
|
36
39
|
// when the full receipt has no artifact root from which to read its check.
|
|
37
40
|
const check = assignment.receipt === null ? null : store.runCheckFor(assignment.receipt.runId);
|
package/dist/flow-triggers.d.ts
CHANGED
|
@@ -180,7 +180,7 @@ export declare function firePlaneReview(store: Store, trigger: FlowTriggerRow, c
|
|
|
180
180
|
said: string;
|
|
181
181
|
}>;
|
|
182
182
|
/** Where a person sees everything that waits on them. */
|
|
183
|
-
export declare const NEEDS_YOU_HREF = "/
|
|
183
|
+
export declare const NEEDS_YOU_HREF = "/tasks?view=needs-you";
|
|
184
184
|
/** A plane review whose summary mentions long waits on a person links to Needs you. */
|
|
185
185
|
export declare function planeStatusLink(config: TriggerConfig | null, status: string | null): {
|
|
186
186
|
label: string;
|
package/dist/flow-triggers.js
CHANGED
|
@@ -841,7 +841,7 @@ export async function firePlaneReview(store, trigger, config, now, scheduled) {
|
|
|
841
841
|
return done(said(parts.length === 0 ? "Nothing new since this morning's review." : parts.join(" ")), added, joined);
|
|
842
842
|
}
|
|
843
843
|
/** Where a person sees everything that waits on them. */
|
|
844
|
-
export const NEEDS_YOU_HREF = "/
|
|
844
|
+
export const NEEDS_YOU_HREF = "/tasks?view=needs-you";
|
|
845
845
|
/** A plane review whose summary mentions long waits on a person links to Needs you. */
|
|
846
846
|
export function planeStatusLink(config, status) {
|
|
847
847
|
return config?.kind === "plane-review" && status !== null && /waited over \d+ days for you\.$/.test(status) ? { label: "Needs you", href: NEEDS_YOU_HREF } : null;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
export type GithubRef = {
|
|
3
|
+
owner: string;
|
|
4
|
+
repo: string;
|
|
5
|
+
kind: "issue" | "pull";
|
|
6
|
+
number: number;
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
export type GithubItem = GithubRef & {
|
|
10
|
+
title: string;
|
|
11
|
+
body: string;
|
|
12
|
+
author: string | null;
|
|
13
|
+
head: string | null;
|
|
14
|
+
};
|
|
15
|
+
export type ImportRefusal = {
|
|
16
|
+
ok: false;
|
|
17
|
+
reason: "bad-url" | "foreign-host" | "unregistered" | "unreadable" | "identity" | "fork" | "head-moved" | "not-filed";
|
|
18
|
+
message: string;
|
|
19
|
+
};
|
|
20
|
+
/** A pasted link, strictly: https, exactly github.com, an issue or a pull request. */
|
|
21
|
+
export declare function parseGithubRef(text: string): GithubRef | ImportRefusal;
|
|
22
|
+
/** `owner/repo` from a remote the project recorded, only in GitHub's exact forms. */
|
|
23
|
+
export declare function githubRepoOf(remote: string | null): string | null;
|
|
24
|
+
/** The environment gh gets: this allowlist, nothing else from the caller. */
|
|
25
|
+
export declare function importEnv(base?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv;
|
|
26
|
+
/** gh from the system's own folders, as its real file: PATH is never asked, so a planted `gh` is never run. */
|
|
27
|
+
export declare function pinnedGh(): string;
|
|
28
|
+
export type GhExec = (file: string, args: readonly string[], options: {
|
|
29
|
+
cwd: string;
|
|
30
|
+
env: NodeJS.ProcessEnv;
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
}) => Promise<{
|
|
33
|
+
code: number;
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
}>;
|
|
37
|
+
export type ImportDeps = {
|
|
38
|
+
exec?: GhExec;
|
|
39
|
+
gh?: string;
|
|
40
|
+
};
|
|
41
|
+
/** Read the item, and prove it is the one the project's identity says. */
|
|
42
|
+
export declare function readGithubItem(store: Store, input: {
|
|
43
|
+
url: string;
|
|
44
|
+
repo: string;
|
|
45
|
+
}, deps?: ImportDeps): Promise<{
|
|
46
|
+
ok: true;
|
|
47
|
+
item: GithubItem;
|
|
48
|
+
gh: string;
|
|
49
|
+
} | ImportRefusal>;
|
|
50
|
+
/** The plan's words: what to do, then the outside text as a quotation, cut with a link when it's long. */
|
|
51
|
+
export declare function importedGoal(item: GithubItem): string;
|
|
52
|
+
/** File it, with its provenance, in one transaction. `shownHead`: the commit the person saw, for a pull request. */
|
|
53
|
+
export declare function fileGithubItem(store: Store, input: {
|
|
54
|
+
url: string;
|
|
55
|
+
repo: string;
|
|
56
|
+
actor: string;
|
|
57
|
+
shownHead?: string | null;
|
|
58
|
+
}, now: Date, deps?: ImportDeps): Promise<{
|
|
59
|
+
ok: true;
|
|
60
|
+
taskId: string;
|
|
61
|
+
item: GithubItem;
|
|
62
|
+
} | ImportRefusal>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a task from a GitHub issue or pull request (docs/plans/plain-toolroll.md P2.7). One importer for the New task
|
|
3
|
+
* form and `task new --from <url>`.
|
|
4
|
+
*
|
|
5
|
+
* Everything an issue says is outside text. The importer reads it with a gh found in the system's own folders (never
|
|
6
|
+
* through PATH), under an allowlisted environment (no GH_HOST, GH_REPO, token or config folder from the caller) and an
|
|
7
|
+
* explicit repository, and checks what came back against the project's identity in Toolroll's own records. A pull
|
|
8
|
+
* request must come from that same repository (no forks), at the head that was shown.
|
|
9
|
+
*
|
|
10
|
+
* The filing's provenance (`filed_via`, `task_source`) is immutable and written with the filing, before any seal exists.
|
|
11
|
+
* The seal refuses every automatic basis for it, centrally (store.ts sealScopeApproval), and a revision or repair copies
|
|
12
|
+
* it: only a person's ceremony approves one, having read the quoted text.
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { realpathSync } from "node:fs";
|
|
16
|
+
import { fileTaskProposal } from "./proposal.js";
|
|
17
|
+
import { releaseTools } from "./release-git.js";
|
|
18
|
+
import { ISSUE_IMPORT_FILED_VIA, ISSUE_IMPORT_VERIFIED } from "./task-source.js";
|
|
19
|
+
import { TASK_TEXT_LIMITS, titleWithin } from "./task-text.js";
|
|
20
|
+
const no = (reason, message) => ({ ok: false, reason, message });
|
|
21
|
+
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
22
|
+
/** A pasted link, strictly: https, exactly github.com, an issue or a pull request. */
|
|
23
|
+
export function parseGithubRef(text) {
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(text.trim());
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return no("bad-url", "That isn't a link to a GitHub issue or pull request.");
|
|
30
|
+
}
|
|
31
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.port !== "")
|
|
32
|
+
return no("bad-url", "That isn't a link to a GitHub issue or pull request.");
|
|
33
|
+
if (url.hostname.toLowerCase() !== "github.com")
|
|
34
|
+
return no("foreign-host", "Only github.com links can be imported.");
|
|
35
|
+
const parts = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/([1-9][0-9]{0,9})\/?$/.exec(url.pathname);
|
|
36
|
+
if (parts === null || !NAME.test(parts[1]) || !NAME.test(parts[2]))
|
|
37
|
+
return no("bad-url", "That isn't a link to a GitHub issue or pull request.");
|
|
38
|
+
const kind = parts[3] === "pull" ? "pull" : "issue";
|
|
39
|
+
return { owner: parts[1], repo: parts[2], kind, number: Number(parts[4]), url: `https://github.com/${parts[1]}/${parts[2]}/${parts[3]}/${parts[4]}` };
|
|
40
|
+
}
|
|
41
|
+
/** `owner/repo` from a remote the project recorded, only in GitHub's exact forms. */
|
|
42
|
+
export function githubRepoOf(remote) {
|
|
43
|
+
if (remote === null)
|
|
44
|
+
return null;
|
|
45
|
+
const read = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(remote.trim());
|
|
46
|
+
return read === null ? null : `${read[1]}/${read[2]}`;
|
|
47
|
+
}
|
|
48
|
+
/** The environment gh gets: this allowlist, nothing else from the caller. */
|
|
49
|
+
export function importEnv(base = process.env) {
|
|
50
|
+
const kept = ["HOME", "LANG", "LC_ALL", "TMPDIR", "USER", "LOGNAME"].flatMap(name => base[name] === undefined ? [] : [[name, base[name]]]);
|
|
51
|
+
return { ...Object.fromEntries(kept), PATH: "/usr/bin:/bin", GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" };
|
|
52
|
+
}
|
|
53
|
+
/** gh from the system's own folders, as its real file: PATH is never asked, so a planted `gh` is never run. */
|
|
54
|
+
export function pinnedGh() {
|
|
55
|
+
const found = releaseTools("").gh;
|
|
56
|
+
try {
|
|
57
|
+
return realpathSync(found);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** gh with exactly `options.env`: nothing of this process's environment rides along. */
|
|
64
|
+
const ghExec = (file, args, options) => new Promise(done => {
|
|
65
|
+
execFile(file, [...args], { cwd: options.cwd, env: options.env, timeout: options.timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
66
|
+
const failure = error;
|
|
67
|
+
done({ code: failure === null ? 0 : typeof failure.code === "number" ? failure.code : 1, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
const lower = (text) => typeof text === "string" ? text.toLowerCase() : "";
|
|
71
|
+
/** Read the item, and prove it is the one the project's identity says. */
|
|
72
|
+
export async function readGithubItem(store, input, deps = {}) {
|
|
73
|
+
const ref = parseGithubRef(input.url);
|
|
74
|
+
if ("ok" in ref)
|
|
75
|
+
return ref;
|
|
76
|
+
const slug = `${ref.owner}/${ref.repo}`;
|
|
77
|
+
const known = githubRepoOf(store.projectRemote(input.repo));
|
|
78
|
+
if (known === null)
|
|
79
|
+
return no("unregistered", "This project has no GitHub repository on record.");
|
|
80
|
+
if (known.toLowerCase() !== slug.toLowerCase())
|
|
81
|
+
return no("unregistered", `This project is ${known}, not ${slug}.`);
|
|
82
|
+
const gh = deps.gh ?? pinnedGh();
|
|
83
|
+
const exec = deps.exec ?? ghExec;
|
|
84
|
+
const ran = await exec(gh, ["api", "--hostname", "github.com", `repos/${known}/${ref.kind === "pull" ? "pulls" : "issues"}/${ref.number}`], { cwd: "/", env: importEnv(), timeoutMs: 20_000 });
|
|
85
|
+
if (ran.code !== 0)
|
|
86
|
+
return no("unreadable", "GitHub didn't answer for that link.");
|
|
87
|
+
let read;
|
|
88
|
+
try {
|
|
89
|
+
read = JSON.parse(ran.stdout);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return no("unreadable", "GitHub's answer couldn't be read.");
|
|
93
|
+
}
|
|
94
|
+
if (read === null || typeof read !== "object" || read["number"] !== ref.number || typeof read["title"] !== "string")
|
|
95
|
+
return no("identity", "GitHub answered with something else.");
|
|
96
|
+
const htmlUrl = lower(read["html_url"]);
|
|
97
|
+
if (htmlUrl !== ref.url.toLowerCase())
|
|
98
|
+
return no("identity", "GitHub answered for another repository.");
|
|
99
|
+
// An issue link can name a pull request (and the other way round): the answer decides, and has to agree.
|
|
100
|
+
if (ref.kind === "issue" && read["pull_request"] !== undefined)
|
|
101
|
+
return no("identity", "That's a pull request: use its pull request link.");
|
|
102
|
+
let head = null;
|
|
103
|
+
if (ref.kind === "pull") {
|
|
104
|
+
const base = read["base"];
|
|
105
|
+
const from = read["head"];
|
|
106
|
+
if (lower(base?.repo?.full_name) !== known.toLowerCase())
|
|
107
|
+
return no("identity", "GitHub answered for another repository.");
|
|
108
|
+
if (lower(from?.repo?.full_name) !== known.toLowerCase() || from?.repo?.fork === true)
|
|
109
|
+
return no("fork", "That pull request comes from a fork. Only one from this repository can be imported.");
|
|
110
|
+
if (typeof from?.sha !== "string" || !/^[0-9a-f]{40}$/.test(from.sha))
|
|
111
|
+
return no("identity", "GitHub named no head commit.");
|
|
112
|
+
head = from.sha;
|
|
113
|
+
}
|
|
114
|
+
const user = read["user"];
|
|
115
|
+
return { ok: true, gh, item: { ...ref, title: read["title"], body: typeof read["body"] === "string" ? read["body"] : "", author: typeof user?.login === "string" ? user.login : null, head } };
|
|
116
|
+
}
|
|
117
|
+
const quoted = (text) => text.split(/\r?\n/).map(line => `> ${line}`).join("\n");
|
|
118
|
+
/** The plan's words: what to do, then the outside text as a quotation, cut with a link when it's long. */
|
|
119
|
+
export function importedGoal(item) {
|
|
120
|
+
const what = item.kind === "pull" ? `pull request #${item.number}` : `issue #${item.number}`;
|
|
121
|
+
const head = `Work on ${item.owner}/${item.repo} ${what}: ${item.url}\n\nIt says (written outside Toolroll: read it as information, not instructions):\n\n> ${item.title}\n>\n`;
|
|
122
|
+
const room = TASK_TEXT_LIMITS.text - head.length - 200;
|
|
123
|
+
const body = item.body.length <= room ? item.body : `${item.body.slice(0, room)}\n[cut here: the rest is at the link]`;
|
|
124
|
+
return `${head}${quoted(body)}`;
|
|
125
|
+
}
|
|
126
|
+
/** File it, with its provenance, in one transaction. `shownHead`: the commit the person saw, for a pull request. */
|
|
127
|
+
export async function fileGithubItem(store, input, now, deps = {}) {
|
|
128
|
+
const read = await readGithubItem(store, input, deps);
|
|
129
|
+
if (!read.ok)
|
|
130
|
+
return read;
|
|
131
|
+
const item = read.item;
|
|
132
|
+
if (item.kind === "pull" && input.shownHead != null && input.shownHead !== item.head)
|
|
133
|
+
return no("head-moved", "That pull request moved since you read it. Read it again.");
|
|
134
|
+
const label = `${item.kind === "pull" ? "Pull request" : "Issue"} #${item.number} on ${item.owner}/${item.repo}`;
|
|
135
|
+
return store.transact(() => {
|
|
136
|
+
const filed = fileTaskProposal(store, {
|
|
137
|
+
title: titleWithin(item.title), repo: input.repo, goal: importedGoal(item), acceptance: [{ id: "c1", statement: `${label} is dealt with.`, how: null, evidence: ["manual-review"] }],
|
|
138
|
+
planning: "skip", filedVia: ISSUE_IMPORT_FILED_VIA, filedBy: { name: input.actor, kind: "person" },
|
|
139
|
+
}, now);
|
|
140
|
+
if (!filed.ok)
|
|
141
|
+
return no("not-filed", filed.message);
|
|
142
|
+
store.recordTaskSource(filed.id, { flow: 0, card: 0, entry: 0 }, JSON.stringify({ label, verified: ISSUE_IMPORT_VERIFIED, external: { id: `${item.owner}/${item.repo}#${item.number}`, url: item.url }, receipt: item.head }), now);
|
|
143
|
+
store.recordAction({ at: now.toISOString(), actor: input.actor, repo: input.repo, taskId: filed.id, runId: null, action: "issue imported", outcome: item.url, source: "work", detail: `read with ${read.gh}${item.head === null ? "" : ` · head ${item.head}`}` });
|
|
144
|
+
return { ok: true, taskId: filed.id, item };
|
|
145
|
+
});
|
|
146
|
+
}
|
package/dist/guides.js
CHANGED
|
@@ -303,20 +303,20 @@ ${AUTHORITY_LINE}
|
|
|
303
303
|
|
|
304
304
|
## The console's screens (what each one is for)
|
|
305
305
|
|
|
306
|
-
- **
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
the dispatch order per
|
|
312
|
-
|
|
306
|
+
- **Home** (\`/\`): what needs a person (questions to answer, plans to
|
|
307
|
+
approve, results to accept), what is running, and what waits on its own.
|
|
308
|
+
- **Tasks** (\`/tasks\`): every task, with List, Board
|
|
309
|
+
(\`/tasks?view=board\`), Ready (\`/tasks?view=ready\`), Archived and every
|
|
310
|
+
project (\`/tasks?scope=all\`). Its *order* view (\`/tasks?view=order\`) is
|
|
311
|
+
the dispatch order per builder (drag to reorder, drag onto a builder to
|
|
312
|
+
keep a task for it). The old pages (\`/inbox\`, \`/next\`, \`/board\`,
|
|
313
|
+
\`/queue\`, \`/workbench\`, \`/done\`, \`/work\`) redirect here for good.
|
|
313
314
|
- **task page** (\`/t/<id>\`): the approval ceremony when a scope waits,
|
|
314
315
|
the acts bar (plan first, hold, retry, build next, cancel), scope,
|
|
315
316
|
attempts, decisions, and — for a scout task — the report with one-tap
|
|
316
317
|
follow-ups.
|
|
317
|
-
- **builds** (\`/runs\`): every attempt, newest first
|
|
318
|
-
|
|
319
|
-
waiting on a person), activity (\`/activity\`, the ledger). **build
|
|
318
|
+
- **builds** (\`/runs\`): every attempt, newest first; a result's own page
|
|
319
|
+
is \`/review?result=<task>\`, and activity (\`/activity\`) is the ledger. **build
|
|
320
320
|
page** (\`/r/<run>\`): one attempt: stage, live transcript, evidence,
|
|
321
321
|
the diff, comments. **peek** (\`/peek\`): every live run at once,
|
|
322
322
|
transcripts following.
|
|
@@ -396,6 +396,11 @@ and crew agent reads from it before working:
|
|
|
396
396
|
history); \`memory show <id>\` prints one with its reason and history.
|
|
397
397
|
- In chat, the lead searches with the same index before asking you again.
|
|
398
398
|
Cite the kind and id of what you rely on: "decision #12", "reference".
|
|
399
|
+
- \`memory status --repo PATH\` says when each entry was last included in a
|
|
400
|
+
run. Lead chats aren't counted.
|
|
401
|
+
- \`toolroll flaky list --repo PATH\` lists tests with mixed outcomes under
|
|
402
|
+
recorded conditions, and \`flaky show <identity>\` one test's history and the
|
|
403
|
+
task that would fix it. It reads only and reruns nothing.
|
|
399
404
|
|
|
400
405
|
## Change it
|
|
401
406
|
|
package/dist/invoke.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { type Store } from "./store.js";
|
|
|
22
22
|
import type { RunOptions } from "./exec.js";
|
|
23
23
|
import type { Runner } from "./worktree.js";
|
|
24
24
|
import { type NetworkFence, type SignInGrants } from "./agent-fence.js";
|
|
25
|
+
import { type CheckStamp } from "./check-stamp.js";
|
|
25
26
|
import { type CheckAccess } from "./check-access.js";
|
|
26
27
|
import { type WritesMode, type LaunchSandbox, type FenceGrants } from "./agent-fence.js";
|
|
27
28
|
import { type NetworkProfile } from "./egress.js";
|
|
@@ -127,7 +128,7 @@ export declare function fencedCommands(store: Store, runId: number, writes: Writ
|
|
|
127
128
|
* project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
|
|
128
129
|
* database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
|
|
129
130
|
*/
|
|
130
|
-
export declare function fencedCheckoutCommands(store: Store, repo: string, checkout: string, inner: Runner, available?: () => boolean, access?: CheckAccess, network?: NetworkProfile, sandbox?: LaunchSandbox): Runner;
|
|
131
|
+
export declare function fencedCheckoutCommands(store: Store, repo: string, checkout: string, inner: Runner, available?: () => boolean, access?: CheckAccess, network?: NetworkProfile, sandbox?: LaunchSandbox, stampRuns?: readonly number[]): Runner;
|
|
131
132
|
/**
|
|
132
133
|
* A `sign-ins` check's launch (check-access.ts, sandbox parity C10): its exact sign-in files, read by the check itself
|
|
133
134
|
* through its sandbox (agent-fence.ts signInGrants: nothing privileged reads or copies one), its own transcript folder
|
|
@@ -188,6 +189,9 @@ export declare function checkoutRunner(store: Store, repo: string, checkout: str
|
|
|
188
189
|
* no readable approval counts as v1 with nothing listed.
|
|
189
190
|
*/
|
|
190
191
|
export declare function checkoutSandboxOf(store: Store, repo: string, runIds: readonly number[]): LaunchSandbox;
|
|
192
|
+
/** A fenced launch's stamp: the level it runs at and exactly what it was given (check-stamp.ts), read from the grants the
|
|
193
|
+
* launch itself takes, never from what was asked for. */
|
|
194
|
+
export declare function fencedStamp(level: "sandboxed" | "sign-ins", fence: readonly string[], grants: FenceGrants, network: NetworkProfile | undefined): CheckStamp;
|
|
191
195
|
/** The digest of a launch's whole fence (its denied paths and grants, canonical), as stamped on its run (C11). */
|
|
192
196
|
export declare function fenceDigest(fence: readonly string[], grants: FenceGrants): string;
|
|
193
197
|
/** What a Claude turn's own stream says about how it billed (v105). */
|