toolroll 0.9.44 → 0.9.45
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 +11 -0
- package/dist/cli-http.d.ts +5 -16
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.d.ts +2 -2
- package/dist/dispatch.js +8 -4
- package/dist/lead-status.d.ts +7 -3
- package/dist/lead-status.js +21 -12
- package/dist/operate-remote.d.ts +12 -0
- package/dist/operate-remote.js +23 -3
- package/dist/operate.js +34 -11
- package/dist/remote-run.d.ts +4 -0
- package/dist/work-summary.d.ts +4 -1
- package/dist/work-summary.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.45 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **Your projects, your lists.** People with access to some projects can now
|
|
6
|
+
run `status`, `task list` and `ready` remotely, and their agents' list
|
|
7
|
+
tools, and see and count only those projects. A task waiting on one in
|
|
8
|
+
another project says "waits for a task in another project", with no id,
|
|
9
|
+
and queue position counts only your projects. Owners see exactly what they
|
|
10
|
+
did before.
|
|
11
|
+
- Still unavailable remotely for limited access: `brief`, `spend` and
|
|
12
|
+
choosing a task's `--id`.
|
|
13
|
+
|
|
3
14
|
## 0.9.44 — 2026-10-07
|
|
4
15
|
|
|
5
16
|
- **Work from your own laptop.** `toolroll connect https://… --token so_…`
|
package/dist/cli-http.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { Store } from './store.js';
|
|
3
|
+
import type { Principal } from './operate.js';
|
|
3
4
|
import { type RemoteCommandLookup } from './remote-command.js';
|
|
4
5
|
/**
|
|
5
6
|
* `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
|
|
@@ -10,22 +11,10 @@ import { type RemoteCommandLookup } from './remote-command.js';
|
|
|
10
11
|
* Only `Authorization: Bearer so_…` is accepted — never a password, cookie or the owner's saved login — and a request
|
|
11
12
|
* carrying a browser Origin is refused, so a page can never drive it.
|
|
12
13
|
*/
|
|
13
|
-
/** Who a remote command runs as
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
generation: number;
|
|
18
|
-
scope: 'read' | 'act';
|
|
19
|
-
tokenId: string;
|
|
20
|
-
projects: string[] | null;
|
|
21
|
-
};
|
|
22
|
-
export type RunOperateAs = (argv: string[], opts: {
|
|
23
|
-
principal: Principal;
|
|
24
|
-
store: Store;
|
|
25
|
-
write: (s: string) => void;
|
|
26
|
-
files?: Record<string, string>;
|
|
27
|
-
source?: 'api' | 'mcp';
|
|
28
|
-
}) => Promise<number>;
|
|
14
|
+
/** Who a remote command runs as, and the runner: operate.ts's own types, so a change there breaks this build instead of
|
|
15
|
+
* drifting. Type-only: the value is loaded lazily through `run` (operate.ts imports the server that imports this). */
|
|
16
|
+
export type { Principal } from './operate.js';
|
|
17
|
+
export type RunOperateAs = typeof import('./operate.js').runOperateAs;
|
|
29
18
|
export type CliRequest = {
|
|
30
19
|
argv: string[];
|
|
31
20
|
files: Record<string, string>;
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -94,13 +94,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
94
94
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
95
95
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
96
96
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
97
|
-
error?: never;
|
|
98
97
|
active: boolean;
|
|
99
98
|
phase: string;
|
|
100
99
|
detail: string;
|
|
101
100
|
running: boolean;
|
|
102
101
|
canResume: boolean;
|
|
103
102
|
canCancel: boolean;
|
|
103
|
+
error?: never;
|
|
104
104
|
automaticRecovery?: never;
|
|
105
105
|
recoveryAttempts?: never;
|
|
106
106
|
recoveryRepairs?: never;
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -94,8 +94,8 @@ export declare function scopeApprovedForDispatch(store: Store, taskRef: number,
|
|
|
94
94
|
export declare function dispatchRoleFor(store: Store, taskRef: number, now: Date): "builder" | "planner" | "scout";
|
|
95
95
|
/** The first requirement this task fails, in words, or null. */
|
|
96
96
|
export declare function missingCapability(store: Store, taskRef: number, dispatchRepo: string | null, now: Date): string | null;
|
|
97
|
-
/** The read-side lifecycle answer.
|
|
98
|
-
export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date): DispatchDiagnosis | null;
|
|
97
|
+
/** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
|
|
98
|
+
export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date, readableRepos?: readonly string[] | null): DispatchDiagnosis | null;
|
|
99
99
|
export declare function diagnosisIsDispatchable(diagnosis: DispatchDiagnosis | null): boolean;
|
|
100
100
|
/** Attach the same lifecycle answer to the bounded fleet snapshot used by
|
|
101
101
|
* both direct chat and the long-running mate. */
|
package/dist/dispatch.js
CHANGED
|
@@ -163,8 +163,8 @@ function approvedProfile(scope) {
|
|
|
163
163
|
return null;
|
|
164
164
|
return scope.approvedProfile ?? scope.profile ?? null;
|
|
165
165
|
}
|
|
166
|
-
/** The read-side lifecycle answer.
|
|
167
|
-
export function diagnoseTaskDispatch(store, taskId, now) {
|
|
166
|
+
/** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
|
|
167
|
+
export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
|
|
168
168
|
const task = store.getTask(taskId);
|
|
169
169
|
const ref = store.lookupRef(taskId);
|
|
170
170
|
if (task === null || ref === null)
|
|
@@ -251,9 +251,13 @@ export function diagnoseTaskDispatch(store, taskId, now) {
|
|
|
251
251
|
}
|
|
252
252
|
if (local?.code === "dependency") {
|
|
253
253
|
const terminal = local.blockerState === "failed" || local.blockerState === "cancelled";
|
|
254
|
+
const blockerRepo = readableRepos === null ? null : store.lookupRef(local.blockerId)?.repo;
|
|
255
|
+
const hidden = readableRepos !== null && (blockerRepo == null || !readableRepos.includes(blockerRepo));
|
|
256
|
+
const blocker = hidden ? "A task in another project" : local.blockerId;
|
|
257
|
+
const blockerTaskId = hidden ? null : local.blockerId;
|
|
254
258
|
return terminal
|
|
255
|
-
? answer("terminal-dependency", "waiting", "A required task did not finish", `${
|
|
256
|
-
: answer("waiting-dependency", "waiting", "Waiting for another task", `${
|
|
259
|
+
? answer("terminal-dependency", "waiting", "A required task did not finish", `${blocker} ${local.blockerState === "cancelled" ? "was cancelled" : "failed"} before it finished.`, { action: "repair-dependency", blockerTaskId })
|
|
260
|
+
: answer("waiting-dependency", "waiting", "Waiting for another task", `${blocker} must finish before this task can start.`, { blockerTaskId });
|
|
257
261
|
}
|
|
258
262
|
const role = dispatchRoleFor(store, ref.id, now);
|
|
259
263
|
const scope = store.getScope(taskId);
|
package/dist/lead-status.d.ts
CHANGED
|
@@ -101,7 +101,11 @@ export declare function repairStaleStatuses(store: Store, now: Date): {
|
|
|
101
101
|
exitsRecorded: number;
|
|
102
102
|
readyForReview: number;
|
|
103
103
|
};
|
|
104
|
-
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
105
|
+
* `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
|
|
106
|
+
* A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
|
|
107
|
+
* the release check, plan windows and the lead line are empty, never another project's. */
|
|
108
|
+
export declare function installationStatus(store: Store, now: Date, viewer?: string | null, repos?: readonly string[] | null): InstallationStatus;
|
|
109
|
+
/** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
|
|
110
|
+
export declare function renderInstallationStatus(status: InstallationStatus, limited?: boolean): string[];
|
|
107
111
|
export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;
|
package/dist/lead-status.js
CHANGED
|
@@ -181,15 +181,20 @@ export function repairStaleStatuses(store, now) {
|
|
|
181
181
|
const exitsRecorded = store.recordFinishedRunExits(now);
|
|
182
182
|
return { exitsRecorded, readyForReview: workReadyForReview(store, now, { principal: "operator", repos: null, includeUnplaced: true }, 0).count };
|
|
183
183
|
}
|
|
184
|
-
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
185
|
+
* `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
|
|
186
|
+
* A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
|
|
187
|
+
* the release check, plan windows and the lead line are empty, never another project's. */
|
|
188
|
+
export function installationStatus(store, now, viewer = currentActor()?.account ?? null, repos = null) {
|
|
189
|
+
const admitted = (row) => repos === null || typeof row["repo"] === "string" && repos.includes(row["repo"]);
|
|
190
|
+
const access = { principal: "operator", repos, includeUnplaced: repos === null };
|
|
191
|
+
const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.id AS task_ref, ref.external_id AS task, ref.repo
|
|
187
192
|
FROM run INDEXED BY work_unfinished
|
|
188
193
|
JOIN task_ref AS ref ON ref.id = run.task_ref
|
|
189
194
|
JOIN claim ON claim.lease_id = run.lease_id
|
|
190
195
|
WHERE run.outcome IS NULL AND claim.released_at IS NULL AND claim.expires_at > ?
|
|
191
196
|
AND claim.lease_generation = (SELECT MAX(newest.lease_generation) FROM claim AS newest INDEXED BY claim_by_task WHERE newest.task_ref = run.task_ref)
|
|
192
|
-
ORDER BY run.id`).all(now.toISOString());
|
|
197
|
+
ORDER BY run.id`).all(now.toISOString()).filter(admitted);
|
|
193
198
|
const phases = new Map();
|
|
194
199
|
const runningTasks = runningRows.map(row => {
|
|
195
200
|
const phase = phaseWords(phaseOf(row));
|
|
@@ -197,7 +202,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
197
202
|
return { task: String(row["task"]), run: Number(row["id"]), phase };
|
|
198
203
|
});
|
|
199
204
|
const runningRefs = new Set(runningRows.map(row => Number(row["task_ref"])));
|
|
200
|
-
const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.plan, ref.strikes, scope.digest AS scope_digest,
|
|
205
|
+
const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.repo, ref.plan, ref.strikes, scope.digest AS scope_digest,
|
|
201
206
|
scope.profile_state,
|
|
202
207
|
(scope.approved_at IS NOT NULL AND scope.approved_by IS NOT NULL AND scope.approved_digest = scope.digest) AS approved,
|
|
203
208
|
EXISTS (SELECT 1 FROM task_edge WHERE task_edge.blocked = task.id
|
|
@@ -208,7 +213,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
208
213
|
JOIN task_ref AS ref INDEXED BY sqlite_autoindex_task_ref_1 ON ref.backend = ? AND ref.external_id = task.id
|
|
209
214
|
LEFT JOIN task_scope AS scope ON scope.task_id = task.id
|
|
210
215
|
WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN)
|
|
211
|
-
.filter(row => !runningRefs.has(Number(row["task_ref"])));
|
|
216
|
+
.filter(row => !runningRefs.has(Number(row["task_ref"])) && admitted(row));
|
|
212
217
|
const reasons = new Map();
|
|
213
218
|
const queuedTasks = [];
|
|
214
219
|
const pauses = openAuthPauses(store);
|
|
@@ -227,11 +232,11 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
227
232
|
}
|
|
228
233
|
// One per family, from the same projection as the Tasks list: a completed
|
|
229
234
|
// family (root or any revision) and a superseded version never count.
|
|
230
|
-
const ready = workReadyForReview(store, now,
|
|
235
|
+
const ready = workReadyForReview(store, now, access, 5);
|
|
231
236
|
const checkRow = (run) => store.handle.prepare(`SELECT ${CHECK_COLUMNS} FROM run_check AS rc WHERE rc.run = ?`).get(run);
|
|
232
237
|
// Run ids grow with time, so the newest finished release check is the
|
|
233
238
|
// first finished row down the partial index.
|
|
234
|
-
const release = store.handle.prepare(`SELECT run.id, run.finished_at, ${CHECK_COLUMNS}, ref.external_id AS task
|
|
239
|
+
const release = repos !== null ? undefined : store.handle.prepare(`SELECT run.id, run.finished_at, ${CHECK_COLUMNS}, ref.external_id AS task
|
|
235
240
|
FROM run_check AS rc INDEXED BY run_check_release
|
|
236
241
|
JOIN run ON run.id = rc.run
|
|
237
242
|
JOIN task_ref AS ref ON ref.id = run.task_ref
|
|
@@ -247,7 +252,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
247
252
|
},
|
|
248
253
|
waitingForReview: { count: ready.count, results: ready.results.map(one => ({ task: one.taskId, run: one.run, check: checkOf(one.run === null ? undefined : checkRow(one.run)) })) },
|
|
249
254
|
releaseCheck: release === undefined ? null : { task: String(release["task"]), run: Number(release["id"]), finishedAt: release["finished_at"] == null ? null : String(release["finished_at"]), check: checkOf(release) },
|
|
250
|
-
planWindows: store.handle.prepare(`SELECT provider, window, used_percent, window_minutes, resets_at, reached, plan, observed_at
|
|
255
|
+
planWindows: repos !== null ? [] : store.handle.prepare(`SELECT provider, window, used_percent, window_minutes, resets_at, reached, plan, observed_at
|
|
251
256
|
FROM provider_limit INDEXED BY sqlite_autoindex_provider_limit_1 ORDER BY provider, window`).all().map(row => ({
|
|
252
257
|
provider: String(row["provider"]),
|
|
253
258
|
plan: row["plan"] == null ? null : String(row["plan"]),
|
|
@@ -259,16 +264,17 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
259
264
|
observedAt: String(row["observed_at"]),
|
|
260
265
|
})),
|
|
261
266
|
signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
|
|
262
|
-
tasks: workIndexPage(store, now, {
|
|
267
|
+
tasks: workIndexPage(store, now, { ...access, viewer }, { limit: 8 }).items
|
|
263
268
|
.map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
|
|
264
|
-
lead: (() => {
|
|
269
|
+
lead: repos !== null ? null : (() => {
|
|
265
270
|
// Only the asker's own lead: another person's lead is never "Your lead".
|
|
266
271
|
const activity = leadActivity(store, viewer);
|
|
267
272
|
return activity === null ? null : { owner: activity.owner, doing: activity.doing, at: activity.at, task: activity.taskId, line: leadActivityLine(activity, now) };
|
|
268
273
|
})(),
|
|
269
274
|
};
|
|
270
275
|
}
|
|
271
|
-
|
|
276
|
+
/** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
|
|
277
|
+
export function renderInstallationStatus(status, limited = false) {
|
|
272
278
|
const lines = [];
|
|
273
279
|
// A paused provider comes first: it is the one thing a person must do.
|
|
274
280
|
for (const one of status.signIn)
|
|
@@ -284,6 +290,9 @@ export function renderInstallationStatus(status) {
|
|
|
284
290
|
lines.push(status.running.count === 0 ? "Building: none" : `Building: ${status.running.count} — ${status.running.tasks.map(one => `${one.task} (#${one.run}, ${one.phase})`).join(", ")}${status.running.count > status.running.tasks.length ? ", …" : ""}`);
|
|
285
291
|
lines.push(status.queued.count === 0 ? "Queued: none" : `Queued: ${status.queued.count} — ${status.queued.tasks.map(one => `${one.task} (${one.reason})`).join(", ")}${status.queued.count > status.queued.tasks.length ? ", …" : ""}`);
|
|
286
292
|
lines.push(status.waitingForReview.count === 0 ? "Ready for review: none" : `Ready for review: ${status.waitingForReview.count} — ${status.waitingForReview.results.map(one => one.run === null ? one.task : `${one.task} (#${one.run})`).join(", ")}${status.waitingForReview.count > status.waitingForReview.results.length ? ", …" : ""}`);
|
|
293
|
+
// Neither the release check nor plan windows belong to a person's projects: a limited report leaves both out.
|
|
294
|
+
if (limited)
|
|
295
|
+
return lines;
|
|
287
296
|
if (status.releaseCheck === null) {
|
|
288
297
|
lines.push("Release check: none recorded");
|
|
289
298
|
}
|
package/dist/operate-remote.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export declare const REMOTE_MESSAGES: {
|
|
|
33
33
|
/**
|
|
34
34
|
* How a "yes" command finds the project it touches, before it runs. Any --repo given is checked too.
|
|
35
35
|
* installation — reads or acts across every project: only for someone with access to all of them.
|
|
36
|
+
* listing — lists across projects: someone with access to all of them sees everything, as locally; anyone else
|
|
37
|
+
* sees only their own projects (the run's `lens`), with every count taken over those projects alone.
|
|
36
38
|
* repo — needs --repo, naming a project the server knows and the person may use.
|
|
37
39
|
* references — the audited arguments resolve every resource to its stored project before dispatch.
|
|
38
40
|
* self — the command already narrows to the person's own projects (their access is passed in).
|
|
@@ -40,6 +42,8 @@ export declare const REMOTE_MESSAGES: {
|
|
|
40
42
|
*/
|
|
41
43
|
export type RemoteScope = {
|
|
42
44
|
kind: "installation";
|
|
45
|
+
} | {
|
|
46
|
+
kind: "listing";
|
|
43
47
|
} | {
|
|
44
48
|
kind: "repo";
|
|
45
49
|
} | {
|
|
@@ -51,6 +55,14 @@ export type RemoteScope = {
|
|
|
51
55
|
};
|
|
52
56
|
/** Every "yes" row's scope. The remote test holds this to the guide's "yes" rows exactly. */
|
|
53
57
|
export declare const REMOTE_SCOPES: ReadonlyMap<string, RemoteScope>;
|
|
58
|
+
/**
|
|
59
|
+
* Why each "installation" row still needs access to every project rather than listing a person's own. The remote
|
|
60
|
+
* test holds this to the table's "installation" rows exactly, so a newly allowed cross-project command has to be
|
|
61
|
+
* scoped as a listing or stated here; it cannot skip project scoping by omission.
|
|
62
|
+
*/
|
|
63
|
+
export declare const REMOTE_ALL_PROJECTS_ONLY: ReadonlyMap<string, "installation-wide" | "not yet scoped">;
|
|
64
|
+
/** The projects a listing or task reference may disclose: null for someone with access to all of them. */
|
|
65
|
+
export declare function remoteLensOf(store: Store, scope: RemoteScope, allows: (repo: string | null) => boolean): readonly string[] | null;
|
|
54
66
|
/** Flags a remote caller never passes: who they are comes from the token, and the server reads and writes no paths for them. */
|
|
55
67
|
export declare const REMOTE_REFUSED_FLAGS: ReadonlySet<string>;
|
|
56
68
|
/**
|
package/dist/operate-remote.js
CHANGED
|
@@ -31,15 +31,15 @@ export const REMOTE_MESSAGES = {
|
|
|
31
31
|
files: "Send that file with the request; the server reads no paths of its own for you.",
|
|
32
32
|
};
|
|
33
33
|
const installation = { kind: "installation" }, repo = { kind: "repo" }, self = { kind: "self" }, global = { kind: "global" };
|
|
34
|
-
const references = { kind: "references" };
|
|
34
|
+
const references = { kind: "references" }, listing = { kind: "listing" };
|
|
35
35
|
/** Every "yes" row's scope. The remote test holds this to the guide's "yes" rows exactly. */
|
|
36
36
|
export const REMOTE_SCOPES = new Map([
|
|
37
|
-
["status",
|
|
37
|
+
["status", listing], ["integrations", installation], ["ready", listing], ["grants", installation], ["sync", installation],
|
|
38
38
|
["gaps", repo], ["task add", repo],
|
|
39
39
|
...["ask", "checks", "add-tests", "show", "wait", "complete", "revise", "state", "block", "unblock", "next", "steer", "assign", "scope", "plan", "hold",
|
|
40
40
|
"unhold", "require", "requeue", "review", "repair", "route", "reopen", "stop", "resume"].map(action => [`task ${action}`, references]),
|
|
41
41
|
["check-progress", references],
|
|
42
|
-
["task list",
|
|
42
|
+
["task list", listing],
|
|
43
43
|
["runner list", installation], ["coordinator list", installation], ["outbox list", installation], ["incident list", installation], ["incident resolve", installation],
|
|
44
44
|
["cap list", repo], ["cap add", repo],
|
|
45
45
|
...["list", "show", "add", "refresh", "pause", "resume", "run-now"].map(action => [`routine ${action}`, installation]),
|
|
@@ -53,6 +53,25 @@ export const REMOTE_SCOPES = new Map([
|
|
|
53
53
|
.map(action => [`flows ${action}`, self]),
|
|
54
54
|
["models status", global], ["models list", global],
|
|
55
55
|
]);
|
|
56
|
+
/**
|
|
57
|
+
* Why each "installation" row still needs access to every project rather than listing a person's own. The remote
|
|
58
|
+
* test holds this to the table's "installation" rows exactly, so a newly allowed cross-project command has to be
|
|
59
|
+
* scoped as a listing or stated here; it cannot skip project scoping by omission.
|
|
60
|
+
*/
|
|
61
|
+
export const REMOTE_ALL_PROJECTS_ONLY = new Map([
|
|
62
|
+
// Settings, connections and acts that belong to the installation as a whole, not to any one project.
|
|
63
|
+
...["integrations", "grants", "sync", "incident resolve", "routine add", "routine refresh", "routine pause", "routine resume", "routine run-now",
|
|
64
|
+
"config show", "intake run", "contest exclude", "webhook status", "webhook test"].map(row => [row, "installation-wide"]),
|
|
65
|
+
// Cross-project lists and reads a limited person is still refused; scoping them is later work.
|
|
66
|
+
...["runner list", "coordinator list", "outbox list", "incident list", "routine list", "routine show", "intake show", "intake preview",
|
|
67
|
+
"intake pr-comments", "template list", "template show", "contest show", "review show"].map(row => [row, "not yet scoped"]),
|
|
68
|
+
]);
|
|
69
|
+
/** The projects a listing or task reference may disclose: null for someone with access to all of them. */
|
|
70
|
+
export function remoteLensOf(store, scope, allows) {
|
|
71
|
+
if ((scope.kind !== "listing" && scope.kind !== "references") || allows(null))
|
|
72
|
+
return null;
|
|
73
|
+
return Object.freeze(store.knownRepos().filter(repo => allows(repo)));
|
|
74
|
+
}
|
|
56
75
|
/** Flags a remote caller never passes: who they are comes from the token, and the server reads and writes no paths for them. */
|
|
57
76
|
export const REMOTE_REFUSED_FLAGS = new Set([
|
|
58
77
|
"db", "as", "token", "token-file", "token-env", "password", "key-file", "credentials", "containment", "out", "project-root", "bin", "port", "host",
|
|
@@ -111,6 +130,7 @@ export function remoteProjectOf(store, run, scope, positional, repos, invocation
|
|
|
111
130
|
switch (scope.kind) {
|
|
112
131
|
case "global":
|
|
113
132
|
case "self":
|
|
133
|
+
case "listing":
|
|
114
134
|
return checked;
|
|
115
135
|
case "installation":
|
|
116
136
|
return run.allows(null) ? { ok: true, repo: named, taskId: null } : { ok: false, reason: "all-projects", message: REMOTE_MESSAGES.installation };
|
package/dist/operate.js
CHANGED
|
@@ -154,7 +154,7 @@ import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm,
|
|
|
154
154
|
import { runMateCli, answerContextLines } from "./mate-cli.js";
|
|
155
155
|
import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./mate-doors.js";
|
|
156
156
|
import { verifyApproverByPassword as approverByPassword } from "./principal.js";
|
|
157
|
-
import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
|
|
157
|
+
import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteLensOf, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
|
|
158
158
|
import { projectAuthority } from "./project-access.js";
|
|
159
159
|
import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
|
|
160
160
|
import { tally, spendLine } from "./summary.js";
|
|
@@ -975,17 +975,20 @@ export async function runOperateAs(argv, opts) {
|
|
|
975
975
|
const file = store.databaseFile() ?? options.databaseFile;
|
|
976
976
|
if (file === undefined)
|
|
977
977
|
return refuse("failed", "The server's database has no file.", project.repo, project.taskId);
|
|
978
|
-
|
|
978
|
+
// The task a `task add` filed is only known once it runs; the final history line names it.
|
|
979
|
+
let filed = null;
|
|
980
|
+
const run = Object.freeze({ account: principal.account, scope: proved.scope, tokenName: proved.tokenName, source, secret: remoteSecret(), allows,
|
|
981
|
+
lens: remoteLensOf(store, scope, allows), noteTask: (taskId) => { filed = taskId; }, files: Object.freeze({ ...(opts.files ?? {}) }) });
|
|
979
982
|
record("requested", project.repo, project.taskId, null);
|
|
980
983
|
let code;
|
|
981
984
|
try {
|
|
982
985
|
code = await withRemote(run, () => dispatchOn(root, parsed.positional, parsed.flags, parsed.repoList, { store, write, json, file, now }, options, go => withActor({ account: principal.account, lead: false }, () => projectAuthority.run({ actor: principal.account, repo: project.repo }, go))));
|
|
983
986
|
}
|
|
984
987
|
catch (error) {
|
|
985
|
-
record("failed", project.repo, project.taskId, null);
|
|
988
|
+
record("failed", project.repo, filed ?? project.taskId, null);
|
|
986
989
|
throw error;
|
|
987
990
|
}
|
|
988
|
-
record(code === EXIT.ok ? "done" : code === EXIT.refused ? "refused" : code === EXIT.usage ? "usage" : "failed", project.repo, project.taskId, null);
|
|
991
|
+
record(code === EXIT.ok ? "done" : code === EXIT.refused ? "refused" : code === EXIT.usage ? "usage" : "failed", project.repo, filed ?? project.taskId, null);
|
|
989
992
|
return code;
|
|
990
993
|
}
|
|
991
994
|
/** A file a remote caller sent with the request, by the argument that names it; never a path on this machine. */
|
|
@@ -1307,7 +1310,9 @@ async function readyCommand(flags, context) {
|
|
|
1307
1310
|
write(` ${task.id} ${task.title}`);
|
|
1308
1311
|
return EXIT.ok;
|
|
1309
1312
|
}
|
|
1310
|
-
|
|
1313
|
+
// A remote person limited to some projects (remoteLensOf) sees only theirs, counted over those alone.
|
|
1314
|
+
const lens = context.principal?.lens ?? null;
|
|
1315
|
+
const ready = store.listReady(now).filter(ref => lens === null || ref.repo !== null && lens.includes(ref.repo));
|
|
1311
1316
|
const described = ready.map(ref => describeRef(store, ref, now));
|
|
1312
1317
|
const dispatchableCount = described.filter(one => diagnosisIsDispatchable(one.dispatch)).length;
|
|
1313
1318
|
const pauses = openAuthPauses(store);
|
|
@@ -10097,6 +10102,13 @@ async function statusCommand(positional, flags, context) {
|
|
|
10097
10102
|
return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
|
|
10098
10103
|
// Whose lead the status line follows: the lead's own person, else the remembered login; nobody shows no lead.
|
|
10099
10104
|
const viewer = currentActor()?.account ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name ?? null;
|
|
10105
|
+
// A remote person limited to some projects sees only those (remoteLensOf): their tasks and counts, and nothing
|
|
10106
|
+
// that belongs to the installation or other projects (updates, integrations, builds by project, unsent replies).
|
|
10107
|
+
const lens = context.principal?.lens ?? null;
|
|
10108
|
+
if (lens !== null) {
|
|
10109
|
+
const status = installationStatus(context.store, context.clock(), viewer, lens);
|
|
10110
|
+
return succeed(context.write, context.json, command, { ...status, projects: [] }, () => renderInstallationStatus(status, true));
|
|
10111
|
+
}
|
|
10100
10112
|
const status = installationStatus(context.store, context.clock(), viewer);
|
|
10101
10113
|
// One line, only when a newer Toolroll exists; offline or switched off says nothing.
|
|
10102
10114
|
const current = PACKAGE_VERSION;
|
|
@@ -10427,6 +10439,7 @@ async function addTask(positional, flags, context) {
|
|
|
10427
10439
|
throw new RemoteRefusal("not-found", "Not found.");
|
|
10428
10440
|
}
|
|
10429
10441
|
}
|
|
10442
|
+
remote?.noteTask(filedId);
|
|
10430
10443
|
store.stampFiledVia(store.refFor(BUILT_IN, filedId).id, "cli");
|
|
10431
10444
|
// Placement is explicit, never inferred from where the command happened to
|
|
10432
10445
|
// run: a task filed from the wrong directory would silently bind to it.
|
|
@@ -10707,7 +10720,9 @@ function listTasks(flags, context) {
|
|
|
10707
10720
|
}
|
|
10708
10721
|
let page;
|
|
10709
10722
|
try {
|
|
10710
|
-
|
|
10723
|
+
// A remote person limited to some projects (remoteLensOf) pages and counts only theirs.
|
|
10724
|
+
const lens = context.principal?.lens ?? null;
|
|
10725
|
+
page = workIndexPage(store, now, { principal: 'operator', repos: lens, includeUnplaced: lens === null }, {
|
|
10711
10726
|
view: parseWorkView(rawView ?? null), limit, cursor: text(flags, 'cursor') ?? null,
|
|
10712
10727
|
...(wanted === undefined ? {} : { state: wanted }),
|
|
10713
10728
|
...(text(flags, 'repo') === undefined ? {} : { project: text(flags, 'repo') }),
|
|
@@ -10754,13 +10769,21 @@ function showTask(positional, context) {
|
|
|
10754
10769
|
// whatever readiness this task's runners have reported.
|
|
10755
10770
|
const routed = routeOfTask(store, id, ref, now);
|
|
10756
10771
|
const readiness = store.readinessLookupFor(ref.repo, ref.assignedRunner, now);
|
|
10772
|
+
const lens = context.principal?.lens ?? null;
|
|
10773
|
+
const access = { principal: "operator", repos: lens, includeUnplaced: lens === null, dependencyRepos: lens };
|
|
10774
|
+
const blockers = store.blockers(id);
|
|
10775
|
+
const visibleBlockers = lens === null ? blockers : blockers.filter(blocker => {
|
|
10776
|
+
const repo = store.lookupRef(blocker)?.repo;
|
|
10777
|
+
return repo != null && lens.includes(repo);
|
|
10778
|
+
});
|
|
10757
10779
|
const detail = {
|
|
10758
10780
|
task,
|
|
10759
|
-
work: taskWorkSummaryOf(store, id, now,
|
|
10760
|
-
assignment: assignmentBrief(assignmentOf(store, id, now,
|
|
10781
|
+
work: taskWorkSummaryOf(store, id, now, access),
|
|
10782
|
+
assignment: assignmentBrief(assignmentOf(store, id, now, access, context.evidenceRoot)),
|
|
10761
10783
|
ref: ref.id,
|
|
10762
|
-
|
|
10763
|
-
|
|
10784
|
+
// One phrase for every inaccessible blocker, so even their number stays private.
|
|
10785
|
+
blockedBy: visibleBlockers.length === blockers.length ? visibleBlockers : [...visibleBlockers, "a task in another project"],
|
|
10786
|
+
position: lens === null ? store.queuePosition(id) : null,
|
|
10764
10787
|
reservedFor: ref.assignedRunner,
|
|
10765
10788
|
hold: store.activeHold(ref.id, now),
|
|
10766
10789
|
claim: currentClaim(store, ref.id, now),
|
|
@@ -10785,7 +10808,7 @@ function showTask(positional, context) {
|
|
|
10785
10808
|
// The one automatic review of the latest build: HIGH findings, suggested
|
|
10786
10809
|
// follow-ups (MEDIUM/LOW), or why it was not reviewed.
|
|
10787
10810
|
automaticReview: latestFinished === null ? null : buildReviewOf(store, latestFinished.id),
|
|
10788
|
-
dispatch: diagnoseTaskDispatch(store, id, now),
|
|
10811
|
+
dispatch: diagnoseTaskDispatch(store, id, now, lens),
|
|
10789
10812
|
// v52: the exact-run control the console shows — Stop, Stopping,
|
|
10790
10813
|
// Paused (resume), or the review-retry door — and every stop on record.
|
|
10791
10814
|
control: taskControlOf(store, ref.id, now),
|
package/dist/remote-run.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export type RemoteRun = {
|
|
|
8
8
|
readonly secret: string;
|
|
9
9
|
/** Whether the person may use `repo` now (their token's projects and their account's current access). */
|
|
10
10
|
readonly allows: (repo: string | null) => boolean;
|
|
11
|
+
/** The projects listings and task references may disclose (remoteLensOf); null shows every project. */
|
|
12
|
+
readonly lens: readonly string[] | null;
|
|
13
|
+
/** Called with the task a remote `task add` filed (or replayed), so its action history names it. */
|
|
14
|
+
readonly noteTask: (taskId: string) => void;
|
|
11
15
|
/** Files the caller sent, keyed by the argument that names them. Nothing else is read for input. */
|
|
12
16
|
readonly files: Readonly<Record<string, string>>;
|
|
13
17
|
};
|
package/dist/work-summary.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export declare function openWorkDecisionOf(store: Store, taskRef: number, now: D
|
|
|
41
41
|
export declare function workDecisionAction(taskId: string, decision: NonNullable<WorkFacts["openDecision"]>, principal: WorkPrincipal): WorkAction;
|
|
42
42
|
/** Pure counterpart for callers already holding the same facts. */
|
|
43
43
|
export declare function workSummaryOf(facts: WorkFacts, principal: WorkPrincipal, resultDisplay?: DisplayStatus): WorkSummary;
|
|
44
|
-
export type WorkSummaryAccess =
|
|
44
|
+
export type WorkSummaryAccess = (
|
|
45
45
|
/** `viewer`: the person reading, whose own lead's claim (lead-voice.ts) a task reads as "<name> is on it". */
|
|
46
46
|
{
|
|
47
47
|
principal: "operator";
|
|
@@ -51,6 +51,9 @@ export type WorkSummaryAccess =
|
|
|
51
51
|
} | {
|
|
52
52
|
principal: "coordinator";
|
|
53
53
|
repos: readonly string[];
|
|
54
|
+
}) & {
|
|
55
|
+
/** Remote task reads also restrict dependency details; omitted preserves existing local output. */
|
|
56
|
+
dependencyRepos?: readonly string[] | null;
|
|
54
57
|
};
|
|
55
58
|
/** Admission precedes reading question/proof bodies. Missing and foreign
|
|
56
59
|
* tasks share the same result. The caller must authenticate/revalidate
|
package/dist/work-summary.js
CHANGED
|
@@ -94,7 +94,7 @@ export function taskWorkSummaryOf(store, taskId, now, access) {
|
|
|
94
94
|
const publication = result === null ? null : store.publicationForRun(result.id);
|
|
95
95
|
const facts = {
|
|
96
96
|
id: task.id, title: task.title, repo: ref.repo, state: task.state, updatedAt: task.updatedAt,
|
|
97
|
-
dispatch: diagnoseTaskDispatch(store, taskId, now), liveRunId: live?.id ?? null,
|
|
97
|
+
dispatch: diagnoseTaskDispatch(store, taskId, now, access.dependencyRepos ?? null), liveRunId: live?.id ?? null,
|
|
98
98
|
unfinishedRunId: runs.find(one => one.outcome === null && one.role !== "reviewer")?.id ?? null,
|
|
99
99
|
control: taskControlOf(store, ref.id, now), openDecision: openWorkDecisionOf(store, ref.id, now),
|
|
100
100
|
result: result === null ? null : {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.45",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|