toolroll 0.9.103 → 0.9.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +10 -8
- package/dist/assignment.d.ts +32 -0
- package/dist/assignment.js +140 -4
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-workspace.d.ts +13 -0
- package/dist/contracts/console-api.d.ts +3 -2
- package/dist/contracts/console-api.js +4 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/keymap.d.ts +3 -1
- package/dist/keymap.js +3 -0
- package/dist/local-owner-acts.d.ts +83 -0
- package/dist/local-owner-acts.js +221 -0
- package/dist/local-owner.d.ts +12 -1
- package/dist/local-owner.js +26 -0
- package/dist/operate-remote-arguments.js +4 -2
- package/dist/operate.js +76 -1
- package/dist/plan-auto.js +5 -0
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/public-access.d.ts +9 -0
- package/dist/public-access.js +15 -0
- package/dist/pull-request-flow.js +9 -0
- package/dist/release-card.d.ts +15 -1
- package/dist/release-card.js +35 -12
- package/dist/result-completion.d.ts +6 -2
- package/dist/result-completion.js +13 -7
- package/dist/scope.d.ts +32 -1
- package/dist/scope.js +110 -25
- package/dist/serve.js +13 -14
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +11 -1
- package/dist/server/remote.d.ts +1 -0
- package/dist/server/render-results.d.ts +4 -0
- package/dist/server/render-tasks.d.ts +9 -0
- package/dist/server/render-tasks.js +4 -4
- package/dist/server/route-table.js +4 -0
- package/dist/server/runtime.d.ts +1 -0
- package/dist/server/task-views.js +3 -0
- package/dist/server/tasks.d.ts +25 -0
- package/dist/server/tasks.js +46 -6
- package/dist/surface.js +12 -2
- package/dist/task-outcome-cli.d.ts +18 -0
- package/dist/task-outcome-cli.js +60 -2
- package/dist/tui-contract.d.ts +61 -3
- package/dist/tui-source.js +91 -20
- package/dist/tui.js +54 -54
- package/package.json +2 -2
package/dist/serve.js
CHANGED
|
@@ -20,8 +20,6 @@ import { html, htmlString, jsonScript, textHtml } from "./html.js";
|
|
|
20
20
|
import { browserAssetsAvailable, browserWorkspaceDocument } from './browser-shell.js';
|
|
21
21
|
import { accessKey, countsKey, indexKey, projectSummaryOf, RevisionCache, summaryKey } from './console-chrome.js';
|
|
22
22
|
import { usageChipOf } from './limits-ui.js';
|
|
23
|
-
/** Any header a proxy adds: a request carrying one is never "from this computer" (password freshness, one-time links). */
|
|
24
|
-
const PROXY_HEADER = /^(forwarded|via|x-forwarded-.+|x-real-ip|x-client-ip|x-original-.+|x-cluster-client-ip|true-client-ip|cf-connecting-ip|fastly-client-ip|x-proxyuser-ip)$/i;
|
|
25
23
|
/** How often the background checks look at what is due (docs/plans/get-side-effects.md), and how often this
|
|
26
24
|
* computer's agent sign-ins are asked again. */
|
|
27
25
|
const BACKGROUND_CHECKS_MS = 30_000;
|
|
@@ -137,7 +135,7 @@ import { canonicalProject, projectName, resolveCeiling, rowVisible } from "./pro
|
|
|
137
135
|
import { noteSignInProbe, signInNotices } from "./provider-auth.js";
|
|
138
136
|
import { createConnectionChecker } from "./provider-connection.js";
|
|
139
137
|
import { ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
140
|
-
import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
|
|
138
|
+
import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, requestFromThisComputer, transportOf } from "./public-access.js";
|
|
141
139
|
import { attachReadExecutor, defaultReadWorkers, ReadExecutor } from "./read-executor.js";
|
|
142
140
|
import { cachedRelease, isNewer, runnerVersions, updateChecksOff } from "./releases.js";
|
|
143
141
|
import { limitWords, FLUSH_MS as REQUEST_BUDGET_FLUSH_MS, RequestBudget, SOURCE_BUDGET_DEFAULTS, SourceAdmission } from "./request-budget.js";
|
|
@@ -158,7 +156,7 @@ import { createPeopleHandlers } from './server/people-tokens.js';
|
|
|
158
156
|
import { createSettingsHandlers } from './server/settings.js';
|
|
159
157
|
import { readPurpose, requestContext } from "./server/request-context.js";
|
|
160
158
|
import { inPlaceRoute, prefetchRoute } from "./server/in-place.js";
|
|
161
|
-
import { asLocalOwner, localModeOn, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
|
|
159
|
+
import { LOCAL_OWNER_ELSEWHERE, asLocalOwner, localModeOn, localOwnerRefusal, localOwnerStandsNow } from "./local-owner.js";
|
|
162
160
|
import { defaultAgentOf, fillDefaultRouting } from "./model-choices.js";
|
|
163
161
|
import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP, pinnedTheme, QUEUE_VIEW, refuse, screen, SENSITIVE_INPUT, shell, sidebarScript } from "./server/chrome.js";
|
|
164
162
|
import { BODY_CAP, form, loginHref, passwordSetNext, refusalNext, matchTaskPath, page, projectChatHref, redactedPath, redirect, respond, safeReturn, SHUTDOWN_WAIT_MS, taskChatHref } from "./server/http.js";
|
|
@@ -379,16 +377,9 @@ export function createDecisionServer(options) {
|
|
|
379
377
|
/** v147: this console start's key epoch (easy-start C2): the browser-key wave binds each session's mutation key to it. */
|
|
380
378
|
const keyEpoch = options.keyEpoch ?? randomBytes(16).toString("hex");
|
|
381
379
|
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no sign of a proxy —
|
|
382
|
-
* any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …) disqualifies the request.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (!LOOPBACK_PEERS.has(peer))
|
|
386
|
-
return false;
|
|
387
|
-
for (const name of Object.keys(request.headers))
|
|
388
|
-
if (PROXY_HEADER.test(name))
|
|
389
|
-
return false;
|
|
390
|
-
return /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
|
|
391
|
-
};
|
|
380
|
+
* any forwarding header at all (Forwarded, X-Forwarded-*, X-Real-IP, Via, …) disqualifies the request. The one reading
|
|
381
|
+
* (public-access.ts), which guards.ts also applies to every request a local owner's session makes. */
|
|
382
|
+
const fromThisComputer = (request) => requestFromThisComputer(request);
|
|
392
383
|
/**
|
|
393
384
|
* Cockpit B2: a request's password-freshness facts — the one place they are made (scope.ts honours them). Every
|
|
394
385
|
* reader looks at the session as it is at the moment of the act, never a snapshot taken before the body arrived:
|
|
@@ -911,6 +902,14 @@ export function createDecisionServer(options) {
|
|
|
911
902
|
const refusedToken = {};
|
|
912
903
|
const who = identify(request, touch, edgeRoute?.domain === 'live', refusedToken);
|
|
913
904
|
timeline.mark("signed in");
|
|
905
|
+
// Local-owner slice 0: a local owner's session presented from anywhere but this computer (another address, a
|
|
906
|
+
// proxy, a tailnet or LAN name) signs in no one and changes nothing. Sign-in pages still answer (a password signs
|
|
907
|
+
// in there); everything else is refused in plain words.
|
|
908
|
+
if (refusedToken.elsewhere === true && edgeRoute?.domain !== 'people') {
|
|
909
|
+
request.resume();
|
|
910
|
+
response.setHeader("x-toolroll-refused", "local-owner-elsewhere");
|
|
911
|
+
return respond(response, 403, "text/plain; charset=utf-8", LOCAL_OWNER_ELSEWHERE);
|
|
912
|
+
}
|
|
914
913
|
if (refusedToken.principal !== undefined) {
|
|
915
914
|
if (refuseBudget(requestBudget.admit(refusedToken.principal.tokenId, "api")))
|
|
916
915
|
return;
|
package/dist/server/guards.d.ts
CHANGED
|
@@ -58,6 +58,7 @@ export declare function createSharedGuards(runtime: GuardsRuntime): {
|
|
|
58
58
|
projectRequestAllowed: (route: RouteDeclaration, source: RouteDeclaration['project'], url: URL, who: Who, request: IncomingMessage, response: ServerResponse, body: URLSearchParams | null) => boolean;
|
|
59
59
|
identify: (request: IncomingMessage, touch?: boolean, limited?: boolean, refusedToken?: {
|
|
60
60
|
principal?: Principal;
|
|
61
|
+
elsewhere?: boolean;
|
|
61
62
|
}) => Who | null;
|
|
62
63
|
lookupSession: (candidate: string, touch?: boolean) => Session | null;
|
|
63
64
|
restricted: () => boolean;
|
package/dist/server/guards.js
CHANGED
|
@@ -15,6 +15,7 @@ import { requestContext } from "./request-context.js";
|
|
|
15
15
|
import { refuse } from "./chrome.js";
|
|
16
16
|
import { matchTaskPath } from "./http.js";
|
|
17
17
|
import { PersistentSessions, SESSION_COOKIE, sessionWithinLifetime } from "./session.js";
|
|
18
|
+
import { requestFromThisComputer } from "../public-access.js";
|
|
18
19
|
export function createSharedGuards(runtime) {
|
|
19
20
|
const syncIdentities = syncProjectIdentities();
|
|
20
21
|
// v99: wrong passwords. One address guessing across names runs out of tries here; each name
|
|
@@ -333,7 +334,16 @@ export function createSharedGuards(runtime) {
|
|
|
333
334
|
const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
|
|
334
335
|
if (match === null)
|
|
335
336
|
return null;
|
|
336
|
-
|
|
337
|
+
// Local-owner slice 0: a local owner's session works only from this computer — a loopback peer naming a loopback
|
|
338
|
+
// Host, with no forwarding header — on every request, not only when it was made. From anywhere else it signs in
|
|
339
|
+
// no one, and is not touched (refusedToken.elsewhere says why, for the refusal).
|
|
340
|
+
const found = lookupSession(match[1], false);
|
|
341
|
+
if (found !== null && found.authority !== "full" && !requestFromThisComputer(request)) {
|
|
342
|
+
if (refusedToken !== undefined)
|
|
343
|
+
refusedToken.elsewhere = true;
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const session = touch && found !== null ? lookupSession(match[1], true) : found;
|
|
337
347
|
return session === null ? null : { name: session.name, via: "cookie", session, role: session.role, authority: session.authority };
|
|
338
348
|
}
|
|
339
349
|
/**
|
package/dist/server/remote.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { type Who } from "./session.js";
|
|
|
12
12
|
export interface RemoteRuntime {
|
|
13
13
|
identify: (request: IncomingMessage, touch?: boolean, limited?: boolean, refusedToken?: {
|
|
14
14
|
principal?: Principal;
|
|
15
|
+
elsewhere?: boolean;
|
|
15
16
|
}) => Who | null;
|
|
16
17
|
store: Store;
|
|
17
18
|
admitPasswordSource: (request: IncomingMessage) => Admission;
|
|
@@ -513,6 +513,15 @@ export declare function taskBodyParts(data: {
|
|
|
513
513
|
needsTwo: boolean;
|
|
514
514
|
} | null;
|
|
515
515
|
plan: "requested" | "drafted" | null;
|
|
516
|
+
/** Local-owner slice 2: the planning source this page read (local-owner-acts.ts planSourceOf), which the plan button
|
|
517
|
+
* posts so a local owner's request binds to exactly it. Null when it can't be read. */
|
|
518
|
+
planSource?: string | null;
|
|
519
|
+
/** Local-owner slice 3: the build and approved plan this page read (local-owner-acts.ts retryPinsOf), which Retry posts
|
|
520
|
+
* so a local owner's retry binds to exactly them. */
|
|
521
|
+
retryPins?: {
|
|
522
|
+
run: number;
|
|
523
|
+
digest: string;
|
|
524
|
+
} | null;
|
|
516
525
|
planDocument: string | null;
|
|
517
526
|
planAuto?: boolean;
|
|
518
527
|
/** Hash of the verified plan artifact currently shown. */
|
|
@@ -2096,9 +2096,9 @@ export function taskBodyParts(data) {
|
|
|
2096
2096
|
// A failed task's status card carries Retry itself (the same requeue); here it would be a second ink act.
|
|
2097
2097
|
const cardRetries = task.state === "failed" && data.assignment != null && data.csrf !== "";
|
|
2098
2098
|
const primaryAct = stopControlsActive || cardRetries ? null : stalled && !data.claimed
|
|
2099
|
-
? { html: act("requeue", "Retry, keeping the branch and workspace"), why: "Clears the repeated failures and queues the task again. The branch and workspace are kept.", whyClass: "retry" }
|
|
2099
|
+
? { html: act("requeue", "Retry, keeping the branch and workspace", data.retryPins == null ? {} : { run: String(data.retryPins.run), digest: data.retryPins.digest }), why: "Clears the repeated failures and queues the task again. The branch and workspace are kept.", whyClass: "retry" }
|
|
2100
2100
|
: canPlan
|
|
2101
|
-
? { html: act("plan", "plan first"), why: "plan first sends an agent to read the project, ask you questions and propose a plan. Nothing builds until you approve it.", whyClass: "plan" }
|
|
2101
|
+
? { html: act("plan", "plan first", data.planSource == null ? {} : { source: data.planSource }), why: "plan first sends an agent to read the project, ask you questions and propose a plan. Nothing builds until you approve it.", whyClass: "plan" }
|
|
2102
2102
|
: data.plan === "requested"
|
|
2103
2103
|
? null
|
|
2104
2104
|
: task.state === "queued" && !data.claimed && (data.position?.position ?? 1) > 1
|
|
@@ -2464,9 +2464,9 @@ export function taskBodyParts(data) {
|
|
|
2464
2464
|
};
|
|
2465
2465
|
// Build again, in place, names its form after the card's own act.
|
|
2466
2466
|
const rebuild = data.csrf !== "" && data.assignment?.primaryAction?.code === "retry-task" && data.assignment.need != null && "key" in data.assignment.need && data.assignment.need.key === "rebuild"
|
|
2467
|
-
? { action: `${taskHref(data.assignment.rootId)}/requeue
|
|
2467
|
+
? { action: `${taskHref(data.assignment.rootId)}/requeue`, pins: data.retryPins ?? null } : null;
|
|
2468
2468
|
const retry = failedCard && task.state === "failed" && !data.claimed && !stopControlsActive && data.csrf !== ""
|
|
2469
|
-
? { action: `${taskHref(task.id)}/requeue`, ...(data.failure == null ? {} : { note: retryNoteOf(data.failure.suggestion) }) } : null;
|
|
2469
|
+
? { action: `${taskHref(task.id)}/requeue`, pins: data.retryPins ?? null, ...(data.failure == null ? {} : { note: retryNoteOf(data.failure.suggestion) }) } : null;
|
|
2470
2470
|
// Run checks happens in place: a status row's Run checks posts here and comes back; with nothing to run, the row offers nothing.
|
|
2471
2471
|
const runChecks = data.runChecks ?? null;
|
|
2472
2472
|
// A row's link to a result in Chat opens that result's own page instead: Chat would only lead back here.
|
|
@@ -79,6 +79,10 @@ const LOCAL_POSTS = Object.freeze({
|
|
|
79
79
|
"task.act.resume-arm": "resume-run",
|
|
80
80
|
"task.act.resume": "resume-run",
|
|
81
81
|
"task.act.complete": "complete-task",
|
|
82
|
+
// Local-owner slice 2: the plan button, on its own road (tasks.ts localOwnerPlan; the writer checks this exact act).
|
|
83
|
+
"task.act.plan": "request-plan",
|
|
84
|
+
// Local-owner slice 3: Retry, on its own road (tasks.ts localOwnerRetry; the writer checks this exact act).
|
|
85
|
+
"task.act.requeue": "retry-build",
|
|
82
86
|
"run.act.revise": "request-revision",
|
|
83
87
|
"run.act.note": "request-revision",
|
|
84
88
|
"run.act.comment": "request-revision",
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -92,6 +92,7 @@ export interface ServerRuntime {
|
|
|
92
92
|
taskRooms: TaskRooms;
|
|
93
93
|
identify: (request: IncomingMessage, touch?: boolean, limited?: boolean, refusedToken?: {
|
|
94
94
|
principal?: Principal;
|
|
95
|
+
elsewhere?: boolean;
|
|
95
96
|
}) => Who | null;
|
|
96
97
|
liveCeiling: () => {
|
|
97
98
|
repos: string[];
|
|
@@ -25,6 +25,7 @@ import { assignmentOf } from '../assignment.js';
|
|
|
25
25
|
import { knowledgeContextHtml } from "../knowledge-ui.js";
|
|
26
26
|
import { failedAttemptSentence, failingCheckSuggestion, GENERAL_SUGGESTION, isInternalErrorReason, latestFinishedAttempt, missedRequirementLine, missedRequirementSuggestion, NO_REASON_RECORDED, stopSuggestionOf } from '../needs-you.js';
|
|
27
27
|
import { planAutoPending } from "../plan-auto.js";
|
|
28
|
+
import { planSourceOf, retryPinsOf } from "../local-owner-acts.js";
|
|
28
29
|
import { readKnowledgeSnapshot } from "../project-knowledge.js";
|
|
29
30
|
import { learningView } from "../project-learning.js";
|
|
30
31
|
import { readSkillsSnapshot, skillTestResult } from "../project-skills.js";
|
|
@@ -384,6 +385,8 @@ export function createTaskViews(runtime) {
|
|
|
384
385
|
})(),
|
|
385
386
|
plan: ref?.plan ?? null,
|
|
386
387
|
planAuto: planAutoPending(runtime.store, taskId, now),
|
|
388
|
+
planSource: planSourceOf(runtime.store, taskId),
|
|
389
|
+
retryPins: retryPinsOf(runtime.store, taskId),
|
|
387
390
|
planDocument: planView?.document ?? null,
|
|
388
391
|
planSha: planView?.sha256 ?? null,
|
|
389
392
|
planContract: ref === null || planView === null ? null : planContractViewOf(ref.id, scope),
|
package/dist/server/tasks.d.ts
CHANGED
|
@@ -81,3 +81,28 @@ export type ApprovalOutcome = "approved" | "vote" | "approved-not-rebuilt" | "re
|
|
|
81
81
|
/** The answer to an approval asked in place: its named outcome and the same words the page would show, as JSON. Written
|
|
82
82
|
* only after the approve route's proofs, by the caller that knows the outcome. */
|
|
83
83
|
export declare function answerApprovalInPlace(response: ServerResponse, outcome: ApprovalOutcome, words: string, status: number): void;
|
|
84
|
+
/**
|
|
85
|
+
* The task page's plan button for the laptop's local owner (local-owner slice 2): bound to the planning source the page
|
|
86
|
+
* read (the form's hidden `source`), never the task as it is now; the writer (local-owner-acts.ts) proves the rest in one
|
|
87
|
+
* transaction. No mode pre-authorization and no pins: the full session's road above is never reached from here.
|
|
88
|
+
*/
|
|
89
|
+
export declare function localOwnerPlan(store: Store, taskId: string, source: string | null | undefined, now: Date): {
|
|
90
|
+
ok: true;
|
|
91
|
+
} | {
|
|
92
|
+
ok: false;
|
|
93
|
+
status: number;
|
|
94
|
+
message: string;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Retry for the laptop's local owner (local-owner slice 3): bound to the build and the approved plan the page read (the
|
|
98
|
+
* form's hidden `run` and `digest`), never the task as it is now; the writer proves the rest in one transaction. A form
|
|
99
|
+
* that carries neither (an older page) is refused, never bound to whatever is current.
|
|
100
|
+
*/
|
|
101
|
+
export declare function localOwnerRetry(store: Store, taskId: string, run: string | null | undefined, digest: string | null | undefined, now: Date): {
|
|
102
|
+
ok: true;
|
|
103
|
+
replayed: boolean;
|
|
104
|
+
} | {
|
|
105
|
+
ok: false;
|
|
106
|
+
status: number;
|
|
107
|
+
message: string;
|
|
108
|
+
};
|
package/dist/server/tasks.js
CHANGED
|
@@ -30,6 +30,7 @@ import { codingShippingHtml } from '../coding-shipping-ui.js';
|
|
|
30
30
|
import { codingWorkspaceHtml, codingWorkspaceScript } from '../coding-ui.js';
|
|
31
31
|
import { CodingActionError } from '../coding-workspace.js';
|
|
32
32
|
import { checkResponse, CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
33
|
+
import { LOCAL_TASK_CHANGED, requestPlanAsLocalOwner, retryAsLocalOwner, retryPinsOf } from "../local-owner-acts.js";
|
|
33
34
|
import { hasForbiddenControls, validateNote } from "../decision.js";
|
|
34
35
|
import { builderReadyFor, diagnoseTaskDispatch, firstRunBuilderRepo } from "../dispatch.js";
|
|
35
36
|
import { firstTaskSuggestions, withBuilderStep } from "../first-run.js";
|
|
@@ -276,7 +277,7 @@ export function createTasksHandlers(runtime) {
|
|
|
276
277
|
const latest = latestFinishedAttempt(runs);
|
|
277
278
|
// Retry from here only when the task failed, nothing holds it, and this is its latest attempt or a result it delivered.
|
|
278
279
|
const retry = failedTask?.state === "failed" && (latest?.id === selected.run.id || delivered) && csrf !== "" && who.role === "approver" && store.currentLiveLease(attemptRun.taskRef, now) === null
|
|
279
|
-
? { action: `${taskHref(selected.taskId)}/requeue
|
|
280
|
+
? { action: `${taskHref(selected.taskId)}/requeue`, pins: retryPinsOf(store, selected.taskId) } : null;
|
|
280
281
|
// Accept anyway: the same acceptance this task's latest result takes, never for an older one or one already accepted.
|
|
281
282
|
const family = delivered ? familyOf(selected.taskId) : null;
|
|
282
283
|
const acceptAnyway = delivered && csrf !== "" && who.role === "approver" && family?.current.id === selected.taskId && family.problem === null &&
|
|
@@ -1895,11 +1896,22 @@ export function createTasksHandlers(runtime) {
|
|
|
1895
1896
|
return refuse(response, who, 403, "steering is a browser session's act");
|
|
1896
1897
|
if (note !== "" && !validateNote(note).ok)
|
|
1897
1898
|
return taskScreen(response, who, taskId, "that note will not store, so nothing was retried", 400);
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1899
|
+
// Local-owner slice 3: the laptop's local owner retries its own approved build on its own road, bound to the
|
|
1900
|
+
// build and plan the page read. The same request again files no second note.
|
|
1901
|
+
let replayed = false;
|
|
1902
|
+
if (who.via === "cookie" && who.authority === "local-owner") {
|
|
1903
|
+
const retried = localOwnerRetry(store, taskId, body.get("run"), body.get("digest"), now);
|
|
1904
|
+
if (!retried.ok)
|
|
1905
|
+
return taskScreen(response, who, taskId, retried.message, retried.status);
|
|
1906
|
+
replayed = retried.replayed;
|
|
1907
|
+
}
|
|
1908
|
+
else {
|
|
1909
|
+
const requeued = store.requeueTask(taskId, who.name, now);
|
|
1910
|
+
if (!requeued.ok) {
|
|
1911
|
+
return taskScreen(response, who, taskId, `not requeued: ${requeued.reason}`, 409);
|
|
1912
|
+
}
|
|
1901
1913
|
}
|
|
1902
|
-
if (note !== "") {
|
|
1914
|
+
if (note !== "" && !replayed) {
|
|
1903
1915
|
const filed = store.fileSteerNote(taskId, verifiedAuthor(who.name), note, now);
|
|
1904
1916
|
if (!filed.ok)
|
|
1905
1917
|
return taskScreen(response, who, taskId, "Retried. The note wasn't saved; add it under Steering.", 409);
|
|
@@ -1954,6 +1966,11 @@ export function createTasksHandlers(runtime) {
|
|
|
1954
1966
|
return redirect(response, taskHref(filed.id));
|
|
1955
1967
|
}
|
|
1956
1968
|
case "plan": {
|
|
1969
|
+
// Local-owner slice 2: the laptop's local owner asks on its own road, bound to the planning source it read.
|
|
1970
|
+
if (who.via === "cookie" && who.authority === "local-owner") {
|
|
1971
|
+
const asked = localOwnerPlan(store, taskId, readForm(posted, CONSOLE_FORMS.taskPlan).get("source"), now);
|
|
1972
|
+
return asked.ok ? redirect(response, taskHref(taskId)) : taskScreen(response, who, taskId, asked.message, asked.status);
|
|
1973
|
+
}
|
|
1957
1974
|
// The operator's explicit ask, refused transactionally when the
|
|
1958
1975
|
// moment has passed — approved scope, live claim, or a plan
|
|
1959
1976
|
// already under way (Codex planning review's requestPlan guard).
|
|
@@ -1961,7 +1978,8 @@ export function createTasksHandlers(runtime) {
|
|
|
1961
1978
|
if (!asked.ok) {
|
|
1962
1979
|
return taskScreen(response, who, taskId, `not planned: ${asked.reason}`, 409);
|
|
1963
1980
|
}
|
|
1964
|
-
|
|
1981
|
+
// Only a full session: a local owner never pre-authorizes (plan-auto.ts refuses it there too).
|
|
1982
|
+
if (who.via === "cookie" && who.authority === "full")
|
|
1965
1983
|
authorizePlanUnderMode(store, taskId, who.name, now);
|
|
1966
1984
|
return redirect(response, taskHref(taskId));
|
|
1967
1985
|
}
|
|
@@ -3339,3 +3357,25 @@ export function createReleaseSheetHandlers(runtime) {
|
|
|
3339
3357
|
export function answerApprovalInPlace(response, outcome, words, status) {
|
|
3340
3358
|
respond(response, status, "application/json; charset=utf-8", JSON.stringify({ outcome, words }));
|
|
3341
3359
|
}
|
|
3360
|
+
/**
|
|
3361
|
+
* The task page's plan button for the laptop's local owner (local-owner slice 2): bound to the planning source the page
|
|
3362
|
+
* read (the form's hidden `source`), never the task as it is now; the writer (local-owner-acts.ts) proves the rest in one
|
|
3363
|
+
* transaction. No mode pre-authorization and no pins: the full session's road above is never reached from here.
|
|
3364
|
+
*/
|
|
3365
|
+
export function localOwnerPlan(store, taskId, source, now) {
|
|
3366
|
+
if (typeof source !== "string" || !/^[a-f0-9]{64}$/.test(source))
|
|
3367
|
+
return { ok: false, status: 409, message: LOCAL_TASK_CHANGED };
|
|
3368
|
+
const done = requestPlanAsLocalOwner(store, taskId, source, now);
|
|
3369
|
+
return done.ok ? { ok: true } : { ok: false, status: done.reason === "local-owner" || done.reason === "refused" ? 403 : 409, message: done.message };
|
|
3370
|
+
}
|
|
3371
|
+
/**
|
|
3372
|
+
* Retry for the laptop's local owner (local-owner slice 3): bound to the build and the approved plan the page read (the
|
|
3373
|
+
* form's hidden `run` and `digest`), never the task as it is now; the writer proves the rest in one transaction. A form
|
|
3374
|
+
* that carries neither (an older page) is refused, never bound to whatever is current.
|
|
3375
|
+
*/
|
|
3376
|
+
export function localOwnerRetry(store, taskId, run, digest, now) {
|
|
3377
|
+
if (typeof run !== "string" || !/^[1-9][0-9]{0,14}$/.test(run) || typeof digest !== "string" || !/^[a-f0-9]{32}$/.test(digest))
|
|
3378
|
+
return { ok: false, status: 409, message: LOCAL_TASK_CHANGED };
|
|
3379
|
+
const done = retryAsLocalOwner(store, taskId, { run: Number(run), digest }, now);
|
|
3380
|
+
return done.ok ? { ok: true, replayed: done.replayed } : { ok: false, status: done.reason === "local-owner" || done.reason === "refused" || done.reason === "limit" ? 403 : 409, message: done.message };
|
|
3381
|
+
}
|
package/dist/surface.js
CHANGED
|
@@ -222,7 +222,11 @@ const GUIDE_ROWS = [
|
|
|
222
222
|
{ name: 'token-env', takesValue: true, meaning: 'scoped coordinator credential environment variable' },
|
|
223
223
|
{ name: 'token-file', takesValue: true, meaning: 'scoped coordinator credential file; defaults to selected project reference' },
|
|
224
224
|
...(action === 'complete' ? [{ name: 'digest', takesValue: true, meaning: 'exact receipt from assignment show; required for JSON or scoped agents' },
|
|
225
|
-
{ name: 'pull-request', takesValue: false, meaning: "also open the result's pull request from that commit, as the console's Complete does; an approver's sign-in (--as/--token), never a scoped credential; refuses with pull-requests-off and the setup command" }
|
|
225
|
+
{ name: 'pull-request', takesValue: false, meaning: "also open the result's pull request from that commit, as the console's Complete does; an approver's sign-in (--as/--token), never a scoped credential; refuses with pull-requests-off and the setup command" },
|
|
226
|
+
// Local-owner slice 1: the terminal UI's Accept, for the laptop's local owner only (no password).
|
|
227
|
+
{ name: 'run', takesValue: true, meaning: "the terminal's local owner: the exact build of the result read; required there with --digest" },
|
|
228
|
+
{ name: 'checked', takesValue: false, meaning: "the terminal's local owner: you checked the result yourself; required when it owes your own check" },
|
|
229
|
+
{ name: 'reason', takesValue: true, meaning: "the terminal's local owner: why you accept a result whose requirement isn't shown; required for an exception" }]
|
|
226
230
|
: [{ name: 'feedback', takesValue: true, meaning: 'specific requested change' }, { name: 'run', takesValue: true, meaning: 'exact result run; defaults to current result' }, { name: 'source', takesValue: true, meaning: 'exact scope digest; defaults to current terms' }, { name: 'key', takesValue: true, meaning: '32 hexadecimal characters for exact replay; defaults to a stable feedback identity' }]),
|
|
227
231
|
],
|
|
228
232
|
})),
|
|
@@ -297,7 +301,9 @@ const GUIDE_ROWS = [
|
|
|
297
301
|
positionals: [{ name: "id", required: true, meaning: "the task" }],
|
|
298
302
|
flags: [jsonFlag, dbFlag, keyFlag,
|
|
299
303
|
{ name: "provider", takesValue: true, meaning: "pin which provider plans THIS task (plan phase only)" },
|
|
300
|
-
{ name: "model", takesValue: true, meaning: "the pinned plan model, riding --provider" }
|
|
304
|
+
{ name: "model", takesValue: true, meaning: "the pinned plan model, riding --provider" },
|
|
305
|
+
// Local-owner slice 2: the terminal UI's plan request, for the laptop's local owner only (no password, no pins).
|
|
306
|
+
{ name: "digest", takesValue: true, meaning: "the terminal's local owner: the exact planning source read; required there, and --provider/--model are refused" }],
|
|
301
307
|
notableReasons: ["unknown-task"] },
|
|
302
308
|
{ invocation: "task hold", synopsis: "park a task with a reason", audience: "agent", agentMayInvoke: true, mutation: "keyed",
|
|
303
309
|
positionals: [{ name: "id", required: true, meaning: "the task" }],
|
|
@@ -592,6 +598,10 @@ const LOCAL_ALLOW = {
|
|
|
592
598
|
"task continue": "resume-run",
|
|
593
599
|
"task unhold": "resume-run",
|
|
594
600
|
"task complete": "complete-task",
|
|
601
|
+
// Local-owner slice 2: its own road (operate.ts localPlanCommand); the writer checks this exact act.
|
|
602
|
+
"task plan": "request-plan",
|
|
603
|
+
// Local-owner slice 3: its own road (operate.ts localRetryCommand); the writer checks this exact act.
|
|
604
|
+
"task requeue": "retry-build",
|
|
595
605
|
"task revise": "request-revision",
|
|
596
606
|
"task archive": "archive-task",
|
|
597
607
|
"task restore": "archive-task",
|
|
@@ -28,3 +28,21 @@ export declare function runTaskMergeCommand(positional: readonly string[], flags
|
|
|
28
28
|
}): Promise<number>;
|
|
29
29
|
/** The lines `task show` prints for a task's pull request. */
|
|
30
30
|
export declare function pullRequestLines(view: PullRequestView | null): string[];
|
|
31
|
+
/** Toolroll's own stop proof for every finished build of a task's family whose stop it can prove here. Best effort: a
|
|
32
|
+
* failure leaves the completion's own refusal to say what is still unproven. */
|
|
33
|
+
export declare function proveTaskBuildsStopped(store: Store, task: string, clock: () => Date): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* `task complete` for the laptop's local owner (local-owner slice 1; the terminal UI's Accept): the exact result the
|
|
36
|
+
* screen read, accepted and completed with no password. Its own road: it never reaches a password check (so it never
|
|
37
|
+
* moves the sign-in guard), and is chosen only inside the local owner's chain; there, an owner who no longer stands is
|
|
38
|
+
* refused, never handed to another road. Everything is pinned: `--run` and `--digest` are required (never defaulted
|
|
39
|
+
* to the current result), `--checked` is the person's explicit yes to a check they made, and `--reason` (trimmed, not
|
|
40
|
+
* empty) is an exception's why. No pull request: that needs a password.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runLocalCompletion(positional: readonly string[], flags: Map<string, string | true>, context: {
|
|
43
|
+
store: Store;
|
|
44
|
+
evidenceRoot: string;
|
|
45
|
+
json: boolean;
|
|
46
|
+
write: (line: string) => void;
|
|
47
|
+
now: Date;
|
|
48
|
+
}): Promise<number>;
|
package/dist/task-outcome-cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Everyday task verbs over the existing completion and revision boundaries. */
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
|
|
3
|
+
import { assignmentOf, checkAssignmentAsOperator, completeAsLocalOwner } from './assignment.js';
|
|
4
|
+
import { currentLocalOwner } from './local-owner.js';
|
|
5
|
+
import { validateNote } from './decision.js';
|
|
4
6
|
import { assignmentForCoordinator, coordinatorToken } from './assignment-adapters.js';
|
|
5
7
|
import { authenticateCoordinator } from './coordinator.js';
|
|
6
8
|
import { reproveApprover } from './principal.js';
|
|
@@ -179,7 +181,7 @@ export function pullRequestLines(view) {
|
|
|
179
181
|
}
|
|
180
182
|
/** Toolroll's own stop proof for every finished build of a task's family whose stop it can prove here. Best effort: a
|
|
181
183
|
* failure leaves the completion's own refusal to say what is still unproven. */
|
|
182
|
-
async function proveTaskBuildsStopped(store, task, clock) {
|
|
184
|
+
export async function proveTaskBuildsStopped(store, task, clock) {
|
|
183
185
|
try {
|
|
184
186
|
const family = store.taskFamilyOf(task, null, true);
|
|
185
187
|
const refs = family === null ? [store.lookupRef(task)?.id].filter((one) => one !== undefined) : family.versions.map(version => version.refId);
|
|
@@ -191,3 +193,59 @@ async function proveTaskBuildsStopped(store, task, clock) {
|
|
|
191
193
|
}
|
|
192
194
|
catch { /* the completion below refuses with what is still unproven */ }
|
|
193
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* `task complete` for the laptop's local owner (local-owner slice 1; the terminal UI's Accept): the exact result the
|
|
198
|
+
* screen read, accepted and completed with no password. Its own road: it never reaches a password check (so it never
|
|
199
|
+
* moves the sign-in guard), and is chosen only inside the local owner's chain; there, an owner who no longer stands is
|
|
200
|
+
* refused, never handed to another road. Everything is pinned: `--run` and `--digest` are required (never defaulted
|
|
201
|
+
* to the current result), `--checked` is the person's explicit yes to a check they made, and `--reason` (trimmed, not
|
|
202
|
+
* empty) is an exception's why. No pull request: that needs a password.
|
|
203
|
+
*/
|
|
204
|
+
export async function runLocalCompletion(positional, flags, context) {
|
|
205
|
+
const command = 'task complete';
|
|
206
|
+
const emit = (result, lines) => {
|
|
207
|
+
context.write(context.json ? envelopeJson({ ...result, command }) : result.ok ? (lines ?? []).join('\n') : result.message ?? 'The action was refused.');
|
|
208
|
+
return result.ok ? 0 : result.reason === 'usage' ? 2 : 3;
|
|
209
|
+
};
|
|
210
|
+
const fail = (reason, message) => emit({ ok: false, reason, message });
|
|
211
|
+
const usage = 'Use task complete <task> --run <build> --digest <result> [--checked] [--reason "why"].';
|
|
212
|
+
const allowed = new Set(['json', 'db', 'run', 'digest', 'checked', 'reason', 'pull-request']);
|
|
213
|
+
for (const key of flags.keys())
|
|
214
|
+
if (!allowed.has(key))
|
|
215
|
+
return fail('usage', `--${key} is not a ${command} option.`);
|
|
216
|
+
if (flags.has('pull-request'))
|
|
217
|
+
return fail('local-owner', `Opening a pull request needs your password. ${context.store.accountOf(currentLocalOwner()?.account ?? '')?.passwordSet === true ? 'Sign in with your password to do this.' : 'Set a password first: run `toolroll password set`.'}`);
|
|
218
|
+
const task = positional[0];
|
|
219
|
+
if (positional.length !== 1 || !task || task.length > 64 || /[\x00-\x1f]/.test(task))
|
|
220
|
+
return fail('usage', usage);
|
|
221
|
+
const run = flags.get('run'), digest = flags.get('digest'), checked = flags.get('checked'), reason = flags.get('reason');
|
|
222
|
+
if (typeof run !== 'string' || !/^[1-9]\d{0,14}$/.test(run))
|
|
223
|
+
return fail('usage', `--run needs the exact build number of the result you read. ${usage}`);
|
|
224
|
+
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest))
|
|
225
|
+
return fail('usage', `--digest needs the exact result you read. ${usage}`);
|
|
226
|
+
if (checked !== undefined && checked !== true)
|
|
227
|
+
return fail('usage', '--checked takes no value.');
|
|
228
|
+
let why = null;
|
|
229
|
+
if (reason !== undefined) {
|
|
230
|
+
// An exception's reason says something: trimmed here, at the command's edge, and never empty or only spaces.
|
|
231
|
+
if (typeof reason !== 'string' || reason.trim() === '')
|
|
232
|
+
return fail('usage', 'Say why you accept it anyway: --reason "<why>".');
|
|
233
|
+
const validated = validateNote(reason);
|
|
234
|
+
if (!validated.ok)
|
|
235
|
+
return fail('usage', `--reason: ${validated.problem}.`);
|
|
236
|
+
why = validated.note;
|
|
237
|
+
}
|
|
238
|
+
const { store, now, evidenceRoot } = context;
|
|
239
|
+
// Toolroll's own stop proof first, as every completion (a build it hasn't proven stopped is refused below).
|
|
240
|
+
await proveTaskBuildsStopped(store, task, () => now);
|
|
241
|
+
const done = completeAsLocalOwner(store, task, { runId: Number(run), receiptDigest: digest, checked: checked === true, reason: why }, now, evidenceRoot);
|
|
242
|
+
if (!done.ok)
|
|
243
|
+
return emit(done);
|
|
244
|
+
const { assignment } = done;
|
|
245
|
+
const receipt = assignment.receipt;
|
|
246
|
+
return emit({ ok: true, replayed: done.replayed, accepted: done.accepted, result: assignment }, [
|
|
247
|
+
`${assignment.rootId} · ${done.replayed ? 'Already accepted' : 'Accepted'}`,
|
|
248
|
+
`Result: ${receipt.taskId} · build #${receipt.runId} · ${receipt.head?.slice(0, 7) ?? 'no commit'}`,
|
|
249
|
+
...(assignment.readiness?.checksOff ? [] : [receipt.checks.detail]),
|
|
250
|
+
]);
|
|
251
|
+
}
|
package/dist/tui-contract.d.ts
CHANGED
|
@@ -46,6 +46,16 @@ export type TuiTask = {
|
|
|
46
46
|
readonly decisions: readonly TuiDecision[];
|
|
47
47
|
/** "Can I merge?" (merge-checklist.ts, `toolroll task ready`'s lines): the answer, then each check. Empty: no result. */
|
|
48
48
|
readonly ready?: readonly string[];
|
|
49
|
+
/** `p` (local-owner slice 2): the planning source read now, which the request binds to; null when no plan can be asked. */
|
|
50
|
+
readonly planAsk?: {
|
|
51
|
+
readonly source: string;
|
|
52
|
+
} | null;
|
|
53
|
+
/** `r` (local-owner slice 3): the latest finished build and the approved plan read now, which a retry binds to; null
|
|
54
|
+
* when there is nothing to retry. */
|
|
55
|
+
readonly retry?: {
|
|
56
|
+
readonly runId: number;
|
|
57
|
+
readonly digest: string;
|
|
58
|
+
} | null;
|
|
49
59
|
};
|
|
50
60
|
/** The welcome's steps, as lines (welcome-facts.ts). */
|
|
51
61
|
export type TuiSetup = {
|
|
@@ -85,12 +95,29 @@ export type TuiAct = {
|
|
|
85
95
|
readonly kind: "revise";
|
|
86
96
|
readonly id: string;
|
|
87
97
|
readonly text: string;
|
|
88
|
-
}
|
|
98
|
+
}
|
|
99
|
+
/** Accept: exactly the result the Accept screen read (its build and receipt, pinned when it opened), with the person's
|
|
100
|
+
* explicit yes to their own check, or an exception's reason, when the result owes one. */
|
|
101
|
+
| {
|
|
89
102
|
readonly kind: "complete";
|
|
90
103
|
readonly id: string;
|
|
91
|
-
|
|
104
|
+
readonly runId: number;
|
|
105
|
+
readonly digest: string;
|
|
106
|
+
readonly checked: boolean;
|
|
107
|
+
readonly reason: string | null;
|
|
108
|
+
}
|
|
109
|
+
/** Retry: exactly the build and approved plan the screen read (the command refuses a task that changed meanwhile). */
|
|
110
|
+
| {
|
|
92
111
|
readonly kind: "retry";
|
|
93
112
|
readonly id: string;
|
|
113
|
+
readonly runId: number;
|
|
114
|
+
readonly digest: string;
|
|
115
|
+
}
|
|
116
|
+
/** Plan it: exactly the planning source the screen read (the command refuses a task that changed meanwhile). */
|
|
117
|
+
| {
|
|
118
|
+
readonly kind: "plan";
|
|
119
|
+
readonly id: string;
|
|
120
|
+
readonly source: string;
|
|
94
121
|
} | {
|
|
95
122
|
readonly kind: "answer";
|
|
96
123
|
readonly decision: number;
|
|
@@ -105,7 +132,8 @@ export type TuiFastTerms = {
|
|
|
105
132
|
readonly words: string;
|
|
106
133
|
}[];
|
|
107
134
|
};
|
|
108
|
-
/** A write's answer: its words. `needsPassword`: refused because the local owner may not do it.
|
|
135
|
+
/** A write's answer: its words. `needsPassword`: refused because the local owner may not do it. `changed`: refused
|
|
136
|
+
* because what it named (a result) changed since the screen read it. */
|
|
109
137
|
export type TuiActResult = {
|
|
110
138
|
readonly ok: true;
|
|
111
139
|
readonly message: string;
|
|
@@ -114,6 +142,34 @@ export type TuiActResult = {
|
|
|
114
142
|
readonly ok: false;
|
|
115
143
|
readonly message: string;
|
|
116
144
|
readonly needsPassword: boolean;
|
|
145
|
+
readonly changed?: boolean;
|
|
146
|
+
};
|
|
147
|
+
/** A finished result as the Accept screen reads it once and pins: which build and receipt, and what it shows. */
|
|
148
|
+
export type TuiResult = {
|
|
149
|
+
/** The task Accept names, and the exact result: what the answer submits, never read again before it is sent. */
|
|
150
|
+
readonly id: string;
|
|
151
|
+
readonly runId: number;
|
|
152
|
+
readonly digest: string;
|
|
153
|
+
readonly title: string;
|
|
154
|
+
/** Ready to accept now; when not, why not (a failed check, a build not yet proven stopped), and no Accept is offered. */
|
|
155
|
+
readonly ready: boolean;
|
|
156
|
+
readonly blockers: readonly string[];
|
|
157
|
+
/** The agent's own summary of what it did (empty when it left none). */
|
|
158
|
+
readonly summary: readonly string[];
|
|
159
|
+
/** The files the result changed (the first ten), how many more, or null when they can't be read. */
|
|
160
|
+
readonly files: {
|
|
161
|
+
readonly names: readonly string[];
|
|
162
|
+
readonly more: number;
|
|
163
|
+
} | null;
|
|
164
|
+
/** Each requirement and how it is shown ("Met", "You check", "Not shown yet"…). */
|
|
165
|
+
readonly requirements: readonly {
|
|
166
|
+
readonly word: string;
|
|
167
|
+
readonly statement: string;
|
|
168
|
+
}[];
|
|
169
|
+
/** "Can I merge?": the actual check status, the same lines as `toolroll task ready`. */
|
|
170
|
+
readonly checks: readonly string[];
|
|
171
|
+
/** What accepting it needs from the person: their own check, a reason, or nothing more. */
|
|
172
|
+
readonly owed: "person-check" | "exception" | null;
|
|
117
173
|
};
|
|
118
174
|
/** The plane, as the screen sees it. */
|
|
119
175
|
export interface TuiSource {
|
|
@@ -124,6 +180,8 @@ export interface TuiSource {
|
|
|
124
180
|
/** A counter that moves when the plane's work changes (the store's wake sequence). */
|
|
125
181
|
changed(): number;
|
|
126
182
|
act(act: TuiAct): Promise<TuiActResult>;
|
|
183
|
+
/** The task's current finished result, read once for the Accept screen; null when it has none. */
|
|
184
|
+
result(id: string): Promise<TuiResult | null>;
|
|
127
185
|
/** A small task's exact terms, or null when it takes the ordinary road (New task then files it). */
|
|
128
186
|
fastTerms(text: string, repo: string | null): Promise<TuiFastTerms | null>;
|
|
129
187
|
/** The welcome's steps, or null once setup is done. */
|