toolroll 0.9.33 → 0.9.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +17 -17
- package/dist/browser-crew.js +1 -0
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +124 -63
- package/dist/contracts/contract-test.d.ts +45 -0
- package/dist/contracts/contract-test.js +69 -0
- package/dist/contracts/contract.d.ts +59 -0
- package/dist/contracts/contract.js +181 -0
- package/dist/contracts/plan.d.ts +58 -0
- package/dist/contracts/plan.js +67 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/evidence.js +2 -2
- package/dist/operate.js +52 -10
- package/dist/plan.d.ts +12 -12
- package/dist/plan.js +129 -72
- package/dist/planner-source.js +2 -1
- package/dist/planner.js +55 -25
- package/dist/project-admission.d.ts +93 -0
- package/dist/project-admission.js +134 -0
- package/dist/project-delete-ui.js +5 -2
- package/dist/runner.d.ts +10 -0
- package/dist/runner.js +16 -0
- package/dist/scope.js +4 -3
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +104 -61
- package/dist/store.d.ts +2 -0
- package/dist/store.js +4 -0
- package/dist/surface.js +8 -2
- package/dist/text-limits.d.ts +17 -0
- package/dist/text-limits.js +16 -0
- package/dist/when-html.d.ts +2 -0
- package/dist/when-html.js +13 -0
- package/package.json +3 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding and removing projects: the ONE road for the command line, the lead
|
|
3
|
+
* and the console's own "Add a project". A project is admitted when its
|
|
4
|
+
* folder is proved to be a Git repository, canonicalized to its checkout
|
|
5
|
+
* root, saved in the store's project list, and enrolled in the machine's
|
|
6
|
+
* locked project registry; a running `up` adopts it from there without a
|
|
7
|
+
* restart. Each change is one ledger row naming who made it and from where.
|
|
8
|
+
*
|
|
9
|
+
* Removing is reversible: the project leaves the lists and the builder, and
|
|
10
|
+
* its tasks, results and settings stay saved. Deleting everything Toolroll
|
|
11
|
+
* holds for a project is project-delete.ts, behind its own confirmation.
|
|
12
|
+
*/
|
|
13
|
+
import { statSync } from "node:fs";
|
|
14
|
+
import { userInfo } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { run } from "./exec.js";
|
|
17
|
+
import { canonicalProject, projectName, sameRepo } from "./project.js";
|
|
18
|
+
import { addRepos, loadProjectRegistry, removeRepos, updateRepos } from "./repos.js";
|
|
19
|
+
export const PROJECT_ADDED = "project added";
|
|
20
|
+
export const PROJECT_REMOVED = "project removed";
|
|
21
|
+
/** The checkout root a folder belongs to, canonical; never a shell. */
|
|
22
|
+
export async function proveProjectRoot(path, exec = run) {
|
|
23
|
+
if (path.trim() === "" || path.length > 4096 || /[\x00-\x1f]/.test(path)) {
|
|
24
|
+
return { ok: false, reason: "unavailable", message: "Choose a valid project folder." };
|
|
25
|
+
}
|
|
26
|
+
const canonical = canonicalProject(path);
|
|
27
|
+
if (canonical === null)
|
|
28
|
+
return { ok: false, reason: "unavailable", message: `${path} isn't a folder on this computer.` };
|
|
29
|
+
const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000 });
|
|
30
|
+
const repo = top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
|
|
31
|
+
if (repo === null)
|
|
32
|
+
return { ok: false, reason: "not-git", message: `${path} isn't a Git repository. Run git init there first, or choose a folder inside one.` };
|
|
33
|
+
return { ok: true, repo };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Admit one project. Idempotent: adding a saved project again changes
|
|
37
|
+
* nothing and writes no ledger row. The registry is written first, under
|
|
38
|
+
* its lock; the project row and its ledger row then commit together, so a
|
|
39
|
+
* retry after a failure finishes the job and records it exactly once.
|
|
40
|
+
*/
|
|
41
|
+
export async function admitProject(store, input) {
|
|
42
|
+
const proved = await proveProjectRoot(input.path, input.exec);
|
|
43
|
+
if (!proved.ok)
|
|
44
|
+
return proved;
|
|
45
|
+
const { repo } = proved;
|
|
46
|
+
let registryHad = true;
|
|
47
|
+
if (input.registryFile !== null) {
|
|
48
|
+
const before = await loadProjectRegistry(input.registryFile);
|
|
49
|
+
const enrolled = await updateRepos(input.registryFile, repos => addRepos(repos, [repo]));
|
|
50
|
+
if (!enrolled.ok)
|
|
51
|
+
return { ok: false, reason: enrolled.reason, message: `${projectName(repo)} wasn't added: ${enrolled.message}` };
|
|
52
|
+
registryHad = !("error" in before) && before.repos.includes(repo);
|
|
53
|
+
}
|
|
54
|
+
const added = store.transact(() => {
|
|
55
|
+
const rowHad = store.listProjects().some(one => one.path === repo);
|
|
56
|
+
store.upsertProject(repo, projectName(repo), input.now);
|
|
57
|
+
if (rowHad && registryHad)
|
|
58
|
+
return false;
|
|
59
|
+
store.recordAction({ at: input.now.toISOString(), actor: input.actor.label, repo, taskId: null, runId: null, action: PROJECT_ADDED, outcome: "added", source: "access", detail: `from ${input.actor.origin}` });
|
|
60
|
+
return true;
|
|
61
|
+
});
|
|
62
|
+
return { ok: true, repo, name: projectName(repo), added };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Remove one project from the lists and the builder. Its tasks, results and
|
|
66
|
+
* settings stay saved; adding it again brings it back. A path that isn't a
|
|
67
|
+
* saved project changes nothing.
|
|
68
|
+
*/
|
|
69
|
+
export async function releaseProject(store, input) {
|
|
70
|
+
const asked = canonicalProject(input.path) ?? resolve(input.path);
|
|
71
|
+
const loaded = input.registryFile === null ? { repos: [] } : await loadProjectRegistry(input.registryFile);
|
|
72
|
+
if ("error" in loaded)
|
|
73
|
+
return { ok: false, reason: "registry", message: loaded.error };
|
|
74
|
+
const entries = loaded.repos.filter(one => one === asked || sameRepo(one, asked));
|
|
75
|
+
const rows = store.listProjects().filter(one => one.path === asked || sameRepo(one.path, asked)).map(one => one.path);
|
|
76
|
+
const repo = entries[0] ?? rows[0] ?? asked;
|
|
77
|
+
if (entries.length === 0 && rows.length === 0)
|
|
78
|
+
return { ok: true, repo, name: projectName(repo), removed: false };
|
|
79
|
+
if (entries.length > 0 && input.registryFile !== null) {
|
|
80
|
+
const written = await updateRepos(input.registryFile, repos => removeRepos(repos, entries));
|
|
81
|
+
if (!written.ok)
|
|
82
|
+
return { ok: false, reason: written.reason, message: `${projectName(repo)} wasn't removed: ${written.message}` };
|
|
83
|
+
}
|
|
84
|
+
store.transact(() => {
|
|
85
|
+
for (const path of rows)
|
|
86
|
+
store.forgetProject(path);
|
|
87
|
+
store.recordAction({ at: input.now.toISOString(), actor: input.actor.label, repo, taskId: null, runId: null, action: PROJECT_REMOVED, outcome: "removed", source: "access", detail: `from ${input.actor.origin}` });
|
|
88
|
+
});
|
|
89
|
+
return { ok: true, repo, name: projectName(repo), removed: true };
|
|
90
|
+
}
|
|
91
|
+
/** The registry `up` watches: beside the database, so an isolated database never enrolls into another's list. */
|
|
92
|
+
export function registryBeside(databaseFile) {
|
|
93
|
+
return join(dirname(databaseFile), "repos.json");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Who is adding from the command line. Running it on this computer against
|
|
97
|
+
* a database this user owns is the owner's authority; a lead token acts as
|
|
98
|
+
* `lead for <owner>`, and only for an owner who may manage every project.
|
|
99
|
+
* Any other credential is refused rather than read as the owner.
|
|
100
|
+
*/
|
|
101
|
+
export function commandLineActor(store, input) {
|
|
102
|
+
if (input.leadToken !== undefined) {
|
|
103
|
+
const lead = store.leadFor(input.leadToken);
|
|
104
|
+
if (lead === null)
|
|
105
|
+
return { ok: false, reason: "failed", message: "That lead token isn't valid (revoked, replaced, or its owner can no longer approve). The owner mints a new one with: toolroll lead token" };
|
|
106
|
+
if (!store.isInstanceOperator(lead.owner))
|
|
107
|
+
return { ok: false, reason: "failed", message: `${lead.owner} can't add or remove projects: their account is limited to listed projects.` };
|
|
108
|
+
return { label: `lead for ${lead.owner}`, origin: "lead" };
|
|
109
|
+
}
|
|
110
|
+
const uid = process.getuid?.();
|
|
111
|
+
if (uid !== undefined) {
|
|
112
|
+
const owner = statSync(input.databaseFile, { throwIfNoEntry: false })?.uid;
|
|
113
|
+
if (owner !== undefined && owner !== uid)
|
|
114
|
+
return { ok: false, reason: "failed", message: "Only the user who owns this Toolroll database, or its lead, can add or remove projects." };
|
|
115
|
+
}
|
|
116
|
+
return { label: input.user ?? userInfo().username, origin: "cli" };
|
|
117
|
+
}
|
|
118
|
+
/** Whether a builder is running now (its heartbeat is a minute apart), so an addition is in the console within seconds. */
|
|
119
|
+
export function consoleRunning(store, now) {
|
|
120
|
+
return store.listRunners().some(one => one.retiredAt === null && now.getTime() - Date.parse(one.heartbeatAt) < 3 * 60_000);
|
|
121
|
+
}
|
|
122
|
+
/** The one plain line after an addition: where the project is now. */
|
|
123
|
+
export function addedWords(name, added, running) {
|
|
124
|
+
const where = running ? "it is in the console now" : "it will be in the console when Toolroll starts (toolroll up)";
|
|
125
|
+
return added ? `Added ${name}; ${where}.` : `${name} was already added; ${where}.`;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether a registry entry carries an admission this boundary recorded:
|
|
129
|
+
* its project row. `up` adopts a project added after it started only with
|
|
130
|
+
* this record, so a hand-edited registry line is not authority by itself.
|
|
131
|
+
*/
|
|
132
|
+
export function admissionRecorded(store, repo) {
|
|
133
|
+
return store.listProjects().some(one => one.path === repo);
|
|
134
|
+
}
|
|
@@ -14,7 +14,7 @@ export const PROJECT_DELETE_CSS = `.project-settings,.project-delete{max-width:7
|
|
|
14
14
|
`.project-settings button,.project-delete button{justify-self:start;min-height:36px;white-space:nowrap}.project-delete .actions a{min-height:36px;display:inline-flex;align-items:center}` +
|
|
15
15
|
`.project-settings .builds-at-once{margin-top:20px}.project-settings .builds-at-once h2{font-size:1rem;margin:0 0 4px}.project-settings .builds-at-once form{margin-top:4px;gap:4px}` +
|
|
16
16
|
`.project-settings .builds-at-once .builds-row{display:flex;gap:8px;align-items:stretch}.project-settings .builds-at-once input{width:5.5rem;min-height:44px;margin:0;box-sizing:border-box}.project-settings .builds-at-once button{min-height:44px;margin:0}` +
|
|
17
|
-
`.project-settings .builds-at-once .meta{margin:0}`;
|
|
17
|
+
`.project-settings .builds-at-once .meta{margin:0}.project-settings form.remove-project{margin-top:20px;gap:4px}.project-settings form.remove-project button{min-height:44px}.project-settings form.remove-project .meta{margin:0}`;
|
|
18
18
|
/** Builds at once: one number, one Save. The worker's cap is said only when it lowers the number. */
|
|
19
19
|
function buildsHtml(view, csrf) {
|
|
20
20
|
const b = view.builds;
|
|
@@ -42,7 +42,10 @@ export function projectSettingsHtml(view, csrf, notice) {
|
|
|
42
42
|
`<form method="post" action="/settings/project/delete"><input type="hidden" name="csrf" value="${e(csrf)}"><input type="hidden" name="repo" value="${e(view.repo)}">` +
|
|
43
43
|
`<label><span>Type <strong>${e(view.name)}</strong> to continue</span><input name="name" autocomplete="off" autocapitalize="off" spellcheck="false" required></label>` +
|
|
44
44
|
`<button type="submit">Continue</button></form>`;
|
|
45
|
-
|
|
45
|
+
// Removing is the reversible step: off the lists and the builder, everything kept.
|
|
46
|
+
const remove = `<form class="remove-project" method="post" action="/projects/remove"><input type="hidden" name="csrf" value="${e(csrf)}"><input type="hidden" name="repo" value="${e(view.repo)}">` +
|
|
47
|
+
`<button type="submit">Remove from Toolroll</button><p class="meta">Its tasks and results stay saved. Add it again to bring it back.</p></form>`;
|
|
48
|
+
return `${head}${remove}<details class="danger-zone"${notice.problem ? " open" : ""}><summary>Delete project</summary>${body}</details></section>`;
|
|
46
49
|
}
|
|
47
50
|
/** The second step: exactly what goes, and the password. */
|
|
48
51
|
export function projectDeleteConfirmHtml(view, csrf, problem) {
|
package/dist/runner.d.ts
CHANGED
|
@@ -104,6 +104,16 @@ export declare function addRunnerReposAuthed(store: Store, args: {
|
|
|
104
104
|
token: string;
|
|
105
105
|
repos: readonly string[];
|
|
106
106
|
}, now: Date): AuthResult;
|
|
107
|
+
/**
|
|
108
|
+
* Narrow a live runner's binding when a project is removed, under the same
|
|
109
|
+
* credential and transaction as addRunnerReposAuthed. `up` calls it only
|
|
110
|
+
* after that project's watch has stopped admitting work.
|
|
111
|
+
*/
|
|
112
|
+
export declare function removeRunnerRepoAuthed(store: Store, args: {
|
|
113
|
+
name: string;
|
|
114
|
+
token: string;
|
|
115
|
+
repo: string;
|
|
116
|
+
}, now: Date): AuthResult;
|
|
107
117
|
/**
|
|
108
118
|
* The one runner-name rule (arc 2 finding 13): nonempty, control-free, at
|
|
109
119
|
* most 60 characters — the console form's rule, now shared by every door.
|
package/dist/runner.js
CHANGED
|
@@ -142,6 +142,22 @@ export function addRunnerReposAuthed(store, args, now) {
|
|
|
142
142
|
};
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Narrow a live runner's binding when a project is removed, under the same
|
|
147
|
+
* credential and transaction as addRunnerReposAuthed. `up` calls it only
|
|
148
|
+
* after that project's watch has stopped admitting work.
|
|
149
|
+
*/
|
|
150
|
+
export function removeRunnerRepoAuthed(store, args, now) {
|
|
151
|
+
return store.transact(() => {
|
|
152
|
+
const auth = authenticate(store, args.name, args.token);
|
|
153
|
+
if (!auth.ok)
|
|
154
|
+
return auth;
|
|
155
|
+
const bound = store.bindRunnerRepos(args.name, auth.runner.repos.filter(one => one !== args.repo), now);
|
|
156
|
+
if (!bound.ok)
|
|
157
|
+
return { ok: false, reason: bound.reason };
|
|
158
|
+
return { ok: true, runner: { ...auth.runner, repos: bound.repos } };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
145
161
|
/**
|
|
146
162
|
* The one runner-name rule (arc 2 finding 13): nonempty, control-free, at
|
|
147
163
|
* most 60 characters — the console form's rule, now shared by every door.
|
package/dist/scope.js
CHANGED
|
@@ -30,6 +30,7 @@ import { claimActor, currentActor, parseLeadToken } from "./actor.js";
|
|
|
30
30
|
*/
|
|
31
31
|
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
32
32
|
import { hasForbiddenControls } from "./decision.js";
|
|
33
|
+
import { TEXT_LIMITS } from "./text-limits.js";
|
|
33
34
|
import { NO_READINESS, exactModelId, isRiskLevel, legOf, projectRoute, routeDigestOf, routeFromJson, routeWords, } from "./phase-routing.js";
|
|
34
35
|
/** Same shape as a runner's credential, for the same reasons. */
|
|
35
36
|
function mintToken() {
|
|
@@ -399,11 +400,11 @@ export function profileFromJson(json) {
|
|
|
399
400
|
export const EVIDENCE_KINDS = ["check", "screenshot", "changed-path", "manual-review"];
|
|
400
401
|
export const ACCEPTANCE_LIMITS = {
|
|
401
402
|
criteria: 12,
|
|
402
|
-
id:
|
|
403
|
+
id: TEXT_LIMITS.acceptanceIdBytes,
|
|
403
404
|
// A statement is one testable outcome; three hundred bytes forced people to
|
|
404
405
|
// drop the qualifying clause that made it testable.
|
|
405
|
-
statement:
|
|
406
|
-
how:
|
|
406
|
+
statement: TEXT_LIMITS.acceptanceStatementBytes,
|
|
407
|
+
how: TEXT_LIMITS.acceptanceHowBytes,
|
|
407
408
|
evidenceKinds: EVIDENCE_KINDS.length,
|
|
408
409
|
};
|
|
409
410
|
function acceptanceDescribe(value) {
|
package/dist/serve.d.ts
CHANGED
|
@@ -145,6 +145,10 @@ export type ServeOptions = {
|
|
|
145
145
|
desktopIdentity?: string;
|
|
146
146
|
/** Trusted native admission callback; enrollment rows alone never supply this authority. */
|
|
147
147
|
additionalProjectRepos?: () => readonly string[];
|
|
148
|
+
/** The co-located `up`'s exact admitted repositories, read on every request: its startup projects plus those
|
|
149
|
+
* added since (`repos add`, the lead, the console) and minus those removed. When given it replaces `repos` as
|
|
150
|
+
* the exact part of the ceiling, so an addition or removal shows without a restart. */
|
|
151
|
+
admittedRepos?: () => readonly string[];
|
|
148
152
|
connectionProbe?: typeof execRun;
|
|
149
153
|
connectionHome?: string;
|
|
150
154
|
/** Test seam for Chat's first tasks: the `gh` and `git grep` reads. */
|