toolroll 0.9.6 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -1
- package/dist/actor.d.ts +21 -0
- package/dist/actor.js +48 -0
- package/dist/assignment-adapters.d.ts +2 -0
- package/dist/assignment-adapters.js +2 -0
- package/dist/assignment-ui.js +3 -1
- package/dist/assignment.d.ts +15 -0
- package/dist/assignment.js +80 -16
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +28 -24
- package/dist/browser-workspace.d.ts +117 -2
- package/dist/build-review.d.ts +38 -0
- package/dist/build-review.js +341 -0
- package/dist/builder.d.ts +1 -0
- package/dist/builder.js +33 -13
- package/dist/chat-actions.js +2 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-quiet.js +11 -1
- package/dist/chat-task-actions.d.ts +13 -0
- package/dist/chat-task-actions.js +19 -1
- package/dist/check-levels-ui.d.ts +26 -0
- package/dist/check-levels-ui.js +84 -0
- package/dist/check-levels.d.ts +67 -0
- package/dist/check-levels.js +171 -0
- package/dist/claim.d.ts +22 -0
- package/dist/claim.js +43 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +6 -0
- package/dist/demo-chat.js +4 -1
- package/dist/dispatch.js +9 -3
- package/dist/dispose.d.ts +12 -3
- package/dist/dispose.js +19 -5
- package/dist/exec.d.ts +5 -0
- package/dist/exec.js +30 -4
- package/dist/export.js +3 -2
- package/dist/first-run.d.ts +34 -0
- package/dist/first-run.js +37 -1
- package/dist/flow-gallery-ui.d.ts +30 -0
- package/dist/flow-gallery-ui.js +85 -0
- package/dist/flow-gallery.d.ts +111 -0
- package/dist/flow-gallery.js +445 -0
- package/dist/flow-share.d.ts +102 -0
- package/dist/flow-share.js +614 -0
- package/dist/flow-starters.d.ts +2 -0
- package/dist/flow-starters.js +1 -1
- package/dist/flow-steps.js +7 -0
- package/dist/flow-triggers.js +2 -0
- package/dist/flows-cli.d.ts +56 -0
- package/dist/flows-cli.js +109 -3
- package/dist/flows-ui.d.ts +15 -1
- package/dist/flows-ui.js +31 -6
- package/dist/guides.js +27 -2
- package/dist/invoke.js +5 -0
- package/dist/lead-context.js +6 -2
- package/dist/mate-doors.d.ts +0 -15
- package/dist/mate-doors.js +5 -1
- package/dist/mate-tools.js +9 -4
- package/dist/models-ui.js +1 -1
- package/dist/modes.d.ts +3 -1
- package/dist/modes.js +3 -1
- package/dist/operate.d.ts +4 -1
- package/dist/operate.js +711 -98
- package/dist/process-custody.js +8 -0
- package/dist/project-concurrency.d.ts +62 -0
- package/dist/project-concurrency.js +116 -0
- package/dist/project-delete-ui.d.ts +7 -0
- package/dist/project-delete-ui.js +19 -2
- package/dist/project-delete.js +4 -0
- package/dist/provider.js +16 -0
- package/dist/pull-request-flow.d.ts +9 -0
- package/dist/pull-request-flow.js +21 -3
- package/dist/result-follow-ups.d.ts +101 -0
- package/dist/result-follow-ups.js +333 -0
- package/dist/retention-ui.d.ts +3 -1
- package/dist/retention-ui.js +4 -4
- package/dist/retention.d.ts +13 -6
- package/dist/retention.js +50 -12
- package/dist/review-switch.d.ts +41 -0
- package/dist/review-switch.js +88 -0
- package/dist/reviewer.d.ts +24 -2
- package/dist/reviewer.js +49 -1
- package/dist/scope.js +11 -0
- package/dist/serve.d.ts +211 -2
- package/dist/serve.js +1038 -145
- package/dist/store.d.ts +112 -4
- package/dist/store.js +447 -27
- package/dist/surface.js +36 -9
- package/dist/tailnet.d.ts +11 -0
- package/dist/tailnet.js +47 -0
- package/dist/task-outcome-cli.js +2 -2
- package/dist/task-status.d.ts +13 -3
- package/dist/task-status.js +38 -16
- package/dist/team-cli.js +3 -0
- package/dist/telegram-status.js +2 -2
- package/dist/verification-evidence.js +21 -1
- package/dist/webhooks.js +5 -0
- package/dist/work-index.js +39 -6
- package/dist/work-summary.d.ts +0 -4
- package/dist/work-summary.js +4 -1
- package/dist/workspace-ui.d.ts +6 -1
- package/dist/workspace-ui.js +14 -11
- package/package.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { withActor } from "./actor.js";
|
|
1
2
|
import { repositoryContext, repositoryContextRead } from './repository-context.js';
|
|
3
|
+
import { ADD_TESTS_ACTION, followUpChecksOf, requestFollowUpChecks, fileAddTestsTask, resultCheckLevel } from './result-follow-ups.js';
|
|
4
|
+
import { CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, suggestQuickCommand } from './check-levels.js';
|
|
2
5
|
import { repositoryContextHtml } from './repository-context-ui.js';
|
|
3
6
|
import { browserAssetsAvailable, browserWorkspaceDocument, serveBrowserAsset } from './browser-shell.js';
|
|
4
7
|
import { browserCrewOf, browserCrewFromIndex, browserWorkActionHref, browserProjectsOf, browserNavigationOf } from './browser-workspace.js';
|
|
@@ -19,13 +22,16 @@ import { CHAT_ACTIONS, sharedActionPayload, sharedActionNeedsReview, sharedActio
|
|
|
19
22
|
import { changeSkills, githubSkill, importSkill, readSkillsSnapshot, reviseSkillTest, skillTestResult, skillsView, testSkill } from "./project-skills.js";
|
|
20
23
|
import { skillsHtml, skillsScript, skillsSnapshotHtml, skillTestFeedbackHtml, SKILLS_CSS } from "./skills-ui.js";
|
|
21
24
|
import { toolsHtml, TOOLS_CSS } from "./tools-ui.js";
|
|
22
|
-
import { flowFallbackHtml, flowsListHtml, flowView, FLOWS_CSS } from "./flows-ui.js";
|
|
25
|
+
import { flowFallbackHtml, flowImportHtml, flowsListHtml, flowView, FLOW_IMPORT_SCRIPT, FLOWS_CSS } from "./flows-ui.js";
|
|
26
|
+
import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, planFlowImport } from "./flow-share.js";
|
|
23
27
|
import { BLANK_SOUL, TEAMMATE_CSS, teammatePageHtml, teammatesListHtml } from "./teammates-ui.js";
|
|
24
28
|
import { grantTool, revokeTool, rulesFromForm, setToolRules } from "./teammate-tools.js";
|
|
25
29
|
import { addKitGithubTrigger, addKitSample, kitInstalled, kitOf, setUpKit } from "./kits.js";
|
|
26
30
|
import { CONNECT_CALLBACK, connectionsOf, finishConnect, oneClickOf, startConnect } from "./mcp-connect.js";
|
|
27
31
|
import { KITS_CSS, kitPageHtml, kitsGalleryHtml } from "./kits-ui.js";
|
|
28
32
|
import { STARTERS_CSS, startersHtml } from "./flow-starters-ui.js";
|
|
33
|
+
import { GALLERY_CSS, galleryHtml, galleryUseHtml } from "./flow-gallery-ui.js";
|
|
34
|
+
import { galleryDefaults, galleryDiagram, galleryTemplateOf, previewGallery, useGalleryTemplate } from "./flow-gallery.js";
|
|
29
35
|
import { starterForWork, starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
|
|
30
36
|
import { createTeammateFrom, labelOf, nameOf, saveSoul, setTeammateState, teammateSettings, sendTeammateSummaries } from "./teammate-admin.js";
|
|
31
37
|
import { editMemory, forgetMemory, tellTeammate } from "./teammate-memory.js";
|
|
@@ -55,16 +61,18 @@ import { handleTeamHttp } from './team-http.js';
|
|
|
55
61
|
import { teamWorkspaceHtml } from './team-ui.js';
|
|
56
62
|
import { createTeamRuntime } from './team-runtime.js';
|
|
57
63
|
import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-revision.js";
|
|
58
|
-
import { workIndexPage, workCountsByProject, WorkIndexCursorError } from "./work-index.js";
|
|
64
|
+
import { workIndexPage, workCountsByProject, WorkIndexCursorError, WORK_INDEX_MAX_LIMIT } from "./work-index.js";
|
|
59
65
|
import { openWorkDecisionOf } from "./work-summary.js";
|
|
60
66
|
import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
|
|
61
67
|
import { demoChatHtml, demoThreadHtml, DEMO_CHAT_SCRIPT } from "./demo-chat.js";
|
|
62
68
|
import { pullRequestSettingsHtml, PULL_REQUEST_SETTINGS_CSS } from './pull-request-ui.js';
|
|
69
|
+
import { checkSettingsHtml, CHECK_SETTINGS_CSS } from './check-levels-ui.js';
|
|
70
|
+
import { hasDisguisedText } from './decision.js';
|
|
63
71
|
import { checkPublishing, completeAndOpenPullRequest, mergeAsPerson, pullRequestBlocker, publishingOf, saveMergeSettings, savePublishing, newestPullRequestOf, MERGE_METHODS } from './pull-request-flow.js';
|
|
64
72
|
import { leadBriefHtml, LEAD_CONTEXT_CSS } from './lead-context.js';
|
|
65
73
|
import { assignmentCatchUp } from './assignment-brief.js';
|
|
66
74
|
import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml, assignmentWithEvidence, ASSIGNMENT_CSS } from './assignment-ui.js';
|
|
67
|
-
import { pullRequestFactOf, stageOfDispatch, statusDetailsHtml, statusIconSvg, statusWhyHtml, taskStatusOf, TASK_STATUS_CSS } from './task-status.js';
|
|
75
|
+
import { assignmentStageOf, pullRequestFactOf, stageOfCode, stageOfDispatch, statusDetailsHtml, statusIconSvg, statusWhyHtml, taskStatusOf, TASK_STATUS_CSS } from './task-status.js';
|
|
68
76
|
import { assignmentPresentationOf, historicalAssessmentReason, shortenedMaterialReason } from './assignment-presentation.js';
|
|
69
77
|
import { ledgerBody } from "./ledger-view.js";
|
|
70
78
|
import { EVIDENCE_PACK_CSS, evidencePack, evidencePackHtml, exportDay, ledgerExportChunks } from "./evidence-pack.js";
|
|
@@ -126,7 +134,7 @@ import { projectAuthority } from "./project-access.js";
|
|
|
126
134
|
* repo, only runs whose task belongs to that repo (or to no repo yet).
|
|
127
135
|
*/
|
|
128
136
|
import { createConnectionChecker } from "./provider-connection.js";
|
|
129
|
-
import { liftAuthPause, noteSignInProbe, signInNotices } from "./provider-auth.js";
|
|
137
|
+
import { liftAuthPause, noteSignInProbe, providerName, signInNotices } from "./provider-auth.js";
|
|
130
138
|
import { openRouterModelsCache, openRouterPickerScript } from "./openrouter-models.js";
|
|
131
139
|
import { checkModels, isNewModel, livePin, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "./model-catalog.js";
|
|
132
140
|
import { MODELS_CSS, modelsHtml, modelsScript } from "./models-ui.js";
|
|
@@ -177,7 +185,7 @@ import { verifiedAuthor, LEAD_THREAD, isDigestTime } from "./store.js";
|
|
|
177
185
|
import { digestTimes } from "./digest-times.js";
|
|
178
186
|
import { updateRepos, addRepos, removeRepos } from "./repos.js";
|
|
179
187
|
import { run as execRun } from "./exec.js";
|
|
180
|
-
import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskSuggestions, SANDBOX_COMMAND } from "./first-run.js";
|
|
188
|
+
import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskJourney, firstTaskSuggestions, HOW_IT_WORKS, SANDBOX_COMMAND, signInCommandFor, START_COMMAND } from "./first-run.js";
|
|
181
189
|
/** A user agent, reduced to safe display words — never echoed raw. */
|
|
182
190
|
function oneLineUa(raw) {
|
|
183
191
|
const text = Array.isArray(raw) ? (raw[0] ?? "") : (raw ?? "");
|
|
@@ -221,10 +229,12 @@ import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./
|
|
|
221
229
|
import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
|
|
222
230
|
import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
|
|
223
231
|
import { latestVersionNow } from "./releases.js";
|
|
224
|
-
import { RETENTION_KINDS, lastSweepAt, parsePeriod, retentionPlan } from "./retention.js";
|
|
232
|
+
import { RETENTION_KINDS, lastSweepAt, parsePeriod, periodChoices, retentionPlan } from "./retention.js";
|
|
225
233
|
import { EXPORT_CSS, dataExportHtml } from "./export-ui.js";
|
|
226
234
|
import { buildExport, exportZip } from "./export.js";
|
|
227
235
|
import { PROJECT_DELETE_CSS, projectDeleteConfirmHtml, projectSettingsHtml } from "./project-delete-ui.js";
|
|
236
|
+
import { parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, projectConcurrency, saveProjectConcurrency } from "./project-concurrency.js";
|
|
237
|
+
import { buildReviewOf, findingWords } from "./review-switch.js";
|
|
228
238
|
import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
|
|
229
239
|
import { POLICY_CSS, policyHtml } from "./policy-ui.js";
|
|
230
240
|
import { checkPolicy, parseList, policyParts } from "./policy.js";
|
|
@@ -252,6 +262,13 @@ import { PACKAGE_VERSION } from "./version.js";
|
|
|
252
262
|
import { updateNoticeWords } from "./update-notice.js";
|
|
253
263
|
import { whenHtml } from "./when-html.js";
|
|
254
264
|
const SESSION_COOKIE = "standing-orders_session";
|
|
265
|
+
/** Where `up`'s one-time sign-in link points, and how long it works. */
|
|
266
|
+
export const SIGN_IN_LINK_PATH = "/login/once/";
|
|
267
|
+
export const SIGN_IN_LINK_MS = 10 * 60_000;
|
|
268
|
+
/** The installation fact that the lead was turned on by default, once (its value: the provider). */
|
|
269
|
+
const LEAD_BY_DEFAULT_FACT = "lead-on-by-default";
|
|
270
|
+
/** The installation fact that the phone card was put away. */
|
|
271
|
+
const PHONE_CARD_FACT = "phone-card-dismissed";
|
|
255
272
|
const BODY_CAP = 16 * 1024;
|
|
256
273
|
// URL encoding can triple UTF-8 bytes. Admit the existing bounded task
|
|
257
274
|
// fields (including paths and rubric) before canonical text validation.
|
|
@@ -515,6 +532,30 @@ export function createDecisionServer(options) {
|
|
|
515
532
|
const ssoOffer = () => { const settings = ssoSettings(); return settings === null ? null : { label: settings.label, operatorsOnly: settings.passwords === "operators" }; };
|
|
516
533
|
/** A step-up within this long of the provider checking someone needs no password. */
|
|
517
534
|
const SSO_FRESH_MS = 10 * 60_000;
|
|
535
|
+
// The one-time sign-in link `up` opens in the browser: single use, ten minutes, from this computer only. Only a hash
|
|
536
|
+
// of the code is kept, in memory; the code itself is never written to a log or the ledger.
|
|
537
|
+
const signInLinks = new Map();
|
|
538
|
+
const linkKey = (code) => createHash("sha256").update(code).digest("hex");
|
|
539
|
+
function mintSignInLink(account) {
|
|
540
|
+
const known = store.accountOf(account);
|
|
541
|
+
if (known === null || known.role !== "approver" || known.revokedAt !== null)
|
|
542
|
+
return null;
|
|
543
|
+
for (const [key, one] of signInLinks)
|
|
544
|
+
if (one.expires < Date.now())
|
|
545
|
+
signInLinks.delete(key);
|
|
546
|
+
while (signInLinks.size >= 20)
|
|
547
|
+
signInLinks.delete(signInLinks.keys().next().value);
|
|
548
|
+
const code = randomBytes(32).toString("base64url");
|
|
549
|
+
signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
|
|
550
|
+
return `${SIGN_IN_LINK_PATH}${code}`;
|
|
551
|
+
}
|
|
552
|
+
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
|
|
553
|
+
const fromThisComputer = (request) => {
|
|
554
|
+
const peer = request.socket.remoteAddress ?? "";
|
|
555
|
+
const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
|
|
556
|
+
return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
|
|
557
|
+
&& /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
|
|
558
|
+
};
|
|
518
559
|
async function providerFor(issuer) {
|
|
519
560
|
if (ssoProvider !== null && ssoProvider.issuer === issuer && Date.now() - ssoProvider.at < 10 * 60_000)
|
|
520
561
|
return { ok: true, provider: ssoProvider.provider };
|
|
@@ -607,7 +648,18 @@ export function createDecisionServer(options) {
|
|
|
607
648
|
const projectViewOf = (repo, who) => ({
|
|
608
649
|
repo, name: projectName(repo), holdings: projectHoldings(store, repo), running: projectRunning(store, repo, clock()),
|
|
609
650
|
canDelete: who.via === "cookie" && store.isInstanceOperator(who.name),
|
|
651
|
+
builds: projectBuildsOf(repo, who),
|
|
610
652
|
});
|
|
653
|
+
/** Settings → Project → Builds at once: the saved number, its workers' capacity, and what builds now. */
|
|
654
|
+
const projectBuildsOf = (repo, who) => {
|
|
655
|
+
const capacities = store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo)).map(one => one.capacity);
|
|
656
|
+
return {
|
|
657
|
+
setting: (() => { const file = store.databaseFile(); return file === null ? PROJECT_CONCURRENCY_DEFAULT : projectConcurrency(file, repo); })(),
|
|
658
|
+
capacity: capacities.length === 0 ? null : Math.max(...capacities),
|
|
659
|
+
building: store.runningBuildsByRepo(clock()).get(repo) ?? 0,
|
|
660
|
+
canChange: who.via === "cookie" && who.role === "approver" && store.accountCanAccess(who.name, repo),
|
|
661
|
+
};
|
|
662
|
+
};
|
|
611
663
|
/** Codex's own MCP servers for a project (`codex mcp list --json`), for "Found on this computer"; null when Codex can't say. */
|
|
612
664
|
const codexServers = async (repo) => {
|
|
613
665
|
try {
|
|
@@ -695,7 +747,7 @@ export function createDecisionServer(options) {
|
|
|
695
747
|
const server = createServer((request, response) => {
|
|
696
748
|
void handle(request, response).catch(error => {
|
|
697
749
|
// Every unhandled error is logged (v99): the path and the message, never the request's body or query.
|
|
698
|
-
logEvent("error", "serve.error", { method: request.method, path: new URL(request.url ?? "/", "http://placeholder").pathname, error: error instanceof Error ? error.message : String(error) });
|
|
750
|
+
logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
|
|
699
751
|
if (envValue(process.env, "SERVE_DEBUG") === "1")
|
|
700
752
|
console.error("SERVE ERROR:", error);
|
|
701
753
|
if (!response.headersSent) {
|
|
@@ -730,15 +782,43 @@ export function createDecisionServer(options) {
|
|
|
730
782
|
const cookieSecure = publicOrigin === null ? "" : "; Secure";
|
|
731
783
|
/** Where Google may send someone back to (v89): the public https address, or this computer's own. */
|
|
732
784
|
const consoleOrigin = (host) => host === undefined ? null : publicOrigin !== null && host === publicOrigin.host ? publicOrigin.origin : /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(host) ? `http://${host}` : null;
|
|
785
|
+
/** This computer's tailnet names, the last ones read (onboarding). */
|
|
786
|
+
let tailnet = [];
|
|
787
|
+
if (options.tailnetNames !== undefined) {
|
|
788
|
+
const readTailnet = options.tailnetNames;
|
|
789
|
+
const refresh = () => { void readTailnet().then(names => { tailnet = names; }, () => { }); };
|
|
790
|
+
refresh();
|
|
791
|
+
const timer = setInterval(refresh, 5 * 60_000);
|
|
792
|
+
timer.unref?.();
|
|
793
|
+
server.on("close", () => clearInterval(timer));
|
|
794
|
+
}
|
|
795
|
+
const servedPort = () => {
|
|
796
|
+
const address = server.address();
|
|
797
|
+
return typeof address === "object" && address !== null ? address.port : null;
|
|
798
|
+
};
|
|
733
799
|
/** The names this server answers as. Anything else is a rebind, refused. */
|
|
734
800
|
const allowedHost = (host) => {
|
|
735
801
|
if (host === undefined)
|
|
736
802
|
return false;
|
|
737
|
-
const
|
|
738
|
-
const
|
|
739
|
-
const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
|
|
803
|
+
const port = servedPort();
|
|
804
|
+
const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
|
|
740
805
|
return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
|
|
741
806
|
};
|
|
807
|
+
/** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
|
|
808
|
+
const consoleCommand = (change) => {
|
|
809
|
+
const port = servedPort() ?? 4180;
|
|
810
|
+
const address = server.address();
|
|
811
|
+
const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
|
|
812
|
+
const host = change.host ?? (bound === "127.0.0.1" || bound === "::1" ? null : bound === "::" ? "0.0.0.0" : bound);
|
|
813
|
+
const allowed = [...(options.allowedHosts ?? []), ...(change.allow === undefined ? [] : [change.allow])];
|
|
814
|
+
return [`toolroll ${options.upConsole === true ? "up" : "serve"}`, ...(host === null ? [] : [`--host ${host}`]), ...(port === 4180 ? [] : [`--port ${port}`]),
|
|
815
|
+
...(allowed.length === 0 ? [] : [`--allow-host ${allowed.join(",")}`])].join(" ");
|
|
816
|
+
};
|
|
817
|
+
/** The wrong-host page's facts: the address opened, where the console answers, and the exact command that admits it. */
|
|
818
|
+
const wrongHost = (host) => {
|
|
819
|
+
const opened = host !== undefined && /^[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$|^\[[0-9A-Fa-f:.]{2,45}\](:[0-9]{1,5})?$/.test(host) ? host : null;
|
|
820
|
+
return { opened, served: `127.0.0.1:${servedPort() ?? 4180}`, command: opened === null ? null : consoleCommand({ allow: opened }) };
|
|
821
|
+
};
|
|
742
822
|
/**
|
|
743
823
|
* The one gate every POST passes — parse elsewhere, authorize here. A
|
|
744
824
|
* refusal names its status; null means proceed. Duplicated security fields
|
|
@@ -1065,7 +1145,7 @@ export function createDecisionServer(options) {
|
|
|
1065
1145
|
if (hook.pathname.startsWith("/hooks/"))
|
|
1066
1146
|
return flowHook(request, response, hook);
|
|
1067
1147
|
if (!allowedHost(request.headers.host)) {
|
|
1068
|
-
return respond(response, 421, "text/
|
|
1148
|
+
return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
|
|
1069
1149
|
}
|
|
1070
1150
|
const url = new URL(request.url ?? "/", "http://placeholder");
|
|
1071
1151
|
// A token in a URL is a token in history, logs, and referers. Refused
|
|
@@ -1147,6 +1227,33 @@ export function createDecisionServer(options) {
|
|
|
1147
1227
|
}
|
|
1148
1228
|
return page(response, 200, loginPage(null, loginReturn(url.searchParams.get("return")), ssoOffer()));
|
|
1149
1229
|
}
|
|
1230
|
+
// The one-time link `up` opens (onboarding): it signs the browser on this computer in and lands on Chat. Spent on
|
|
1231
|
+
// the first visit whatever happens; the ledger notes the sign-in, never the link.
|
|
1232
|
+
if (url.pathname.startsWith(SIGN_IN_LINK_PATH) && method === "GET") {
|
|
1233
|
+
const code = url.pathname.slice(SIGN_IN_LINK_PATH.length);
|
|
1234
|
+
const held = /^[A-Za-z0-9_-]{43}$/.test(code) ? signInLinks.get(linkKey(code)) : undefined;
|
|
1235
|
+
if (held !== undefined)
|
|
1236
|
+
signInLinks.delete(linkKey(code));
|
|
1237
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
1238
|
+
if (!fromThisComputer(request)) {
|
|
1239
|
+
recordSignIn(held?.account ?? "unknown account", "sign-in refused", "one-time link", "opened from another device");
|
|
1240
|
+
return page(response, 403, loginPage("That sign-in link works only in a browser on the computer running Toolroll. Sign in with your password.", "/chat", ssoOffer()));
|
|
1241
|
+
}
|
|
1242
|
+
if (held === undefined || held.expires < Date.now()) {
|
|
1243
|
+
return page(response, 410, loginPage("That sign-in link has been used or has expired. Sign in with your password, or run toolroll up again.", "/chat", ssoOffer()));
|
|
1244
|
+
}
|
|
1245
|
+
const account = store.accountOf(held.account);
|
|
1246
|
+
if (account === null || account.role !== "approver" || account.revokedAt !== null)
|
|
1247
|
+
return page(response, 403, loginPage("That account can't sign in here any more.", "/chat", ssoOffer()));
|
|
1248
|
+
if (!passwordAllowed(held.account))
|
|
1249
|
+
return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
|
|
1250
|
+
const id = randomBytes(32).toString("hex");
|
|
1251
|
+
sessions.set(id, { ...arrival(request), name: held.account, csrf: randomBytes(32).toString("hex"), role: account.role, generation: account.generation, createdAt: Date.now(), sawBoardAt: null, lastSeen: Date.now(),
|
|
1252
|
+
project: defaultProject, projectRevision: 1 });
|
|
1253
|
+
recordSignIn(held.account, "signed in", "one-time link");
|
|
1254
|
+
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
1255
|
+
return redirect(response, "/chat");
|
|
1256
|
+
}
|
|
1150
1257
|
// v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
|
|
1151
1258
|
// ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
|
|
1152
1259
|
if (url.pathname === "/login/sso" && method === "GET") {
|
|
@@ -1426,7 +1533,7 @@ export function createDecisionServer(options) {
|
|
|
1426
1533
|
if (method === "GET" || method === "POST")
|
|
1427
1534
|
return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
|
|
1428
1535
|
const taskTextForm = url.pathname === "/tasks/add" || /^\/t\/[^/]+\/scope$/.test(url.pathname);
|
|
1429
|
-
const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
|
|
1536
|
+
const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : url.pathname === "/flows/import" ? 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
|
|
1430
1537
|
const target = actionTarget(url, who, request, body);
|
|
1431
1538
|
const execute = async () => {
|
|
1432
1539
|
if (!projectRequestAllowed(url, who, request, response))
|
|
@@ -1591,8 +1698,8 @@ export function createDecisionServer(options) {
|
|
|
1591
1698
|
const write = new Set(["/settings/flows/on", "/settings/skills/import", "/settings/skills/change", "/settings/skills/revise", "/settings/knowledge/change", "/settings/knowledge/refresh", "/settings/learning/change", "/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/projects/select", "/tasks/add", "/routines/add"]);
|
|
1592
1699
|
const task = matchTaskPath(path, request.method === "GET" ? "(/evidence)?$" : "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|next|reopen|steer|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|stop|resume-arm|resume)$");
|
|
1593
1700
|
const resource = request.method === "GET"
|
|
1594
|
-
? /^\/(?:r|d)\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(path) || /^\/routines\/[0-9]{1,15}$/.test(path) || path === "/flows" || /^\/flows\/[0-9]{1,15}(\/insights|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path)
|
|
1595
|
-
: /^\/d\/[0-9]{1,15}\/answer$/.test(path) || /^\/routines\/[0-9]{1,15}\/(approve|refresh|pause|resume|run-now)$/.test(path) || path === "/flows/new" || /^\/flows\/[0-9]{1,15}\/(save|cards|archive|scripts)$/.test(path) || /^\/flows\/[0-9]{1,15}\/cards\/[0-9]{1,15}\/(move|decide|cancel|comment|assign|watch)$/.test(path) || /^\/flows\/[0-9]{1,15}\/triggers(\/[0-9]{1,15}\/(pause|resume|remove|check|press|renew|secret|share|unshare))?$/.test(path) || /^\/r\/[0-9]{1,15}\/(note|comment|revise|draft-repair)$/.test(path);
|
|
1701
|
+
? /^\/(?:r|d)\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(path) || /^\/routines\/[0-9]{1,15}$/.test(path) || path === "/flows" || /^\/flows\/[0-9]{1,15}(\/insights|\/export|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path) || path === "/flows/new" || /^\/flows\/new\/[a-z-]{1,40}$/.test(path)
|
|
1702
|
+
: /^\/d\/[0-9]{1,15}\/answer$/.test(path) || /^\/routines\/[0-9]{1,15}\/(approve|refresh|pause|resume|run-now)$/.test(path) || path === "/flows/new" || /^\/flows\/new\/[a-z-]{1,40}$/.test(path) || path === "/flows/import" || /^\/flows\/[0-9]{1,15}\/(save|cards|archive|scripts)$/.test(path) || /^\/flows\/[0-9]{1,15}\/cards\/[0-9]{1,15}\/(move|decide|cancel|comment|assign|watch)$/.test(path) || /^\/flows\/[0-9]{1,15}\/triggers(\/[0-9]{1,15}\/(pause|resume|remove|check|press|renew|secret|share|unshare))?$/.test(path) || /^\/r\/[0-9]{1,15}\/(note|comment|revise|draft-repair|checks|add-tests)$/.test(path);
|
|
1596
1703
|
if (!(request.method === "GET" ? read : write).has(path) && task === null && !resource) {
|
|
1597
1704
|
refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
|
|
1598
1705
|
return false;
|
|
@@ -1617,6 +1724,20 @@ export function createDecisionServer(options) {
|
|
|
1617
1724
|
return true;
|
|
1618
1725
|
}
|
|
1619
1726
|
// ---- reads ---------------------------------------------------------------
|
|
1727
|
+
/** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
|
|
1728
|
+
function sendGalleryUse(response, who, template, projects, repo, given, name, notice) {
|
|
1729
|
+
const answers = given ?? galleryDefaults(store, template, repo);
|
|
1730
|
+
let preview = null, problem = notice;
|
|
1731
|
+
try {
|
|
1732
|
+
preview = previewGallery(store, template, repo, answers, who.name, clock());
|
|
1733
|
+
}
|
|
1734
|
+
catch (error) {
|
|
1735
|
+
problem = error instanceof Error ? error.message : "That can't be made here.";
|
|
1736
|
+
}
|
|
1737
|
+
const html = galleryUseHtml({ template, projects: projects.map(path => ({ path, name: projectName(path) })), repo, answers, name, preview, problem,
|
|
1738
|
+
csrf: who.via === "cookie" ? who.session.csrf : "", diagram: preview?.built.definition ?? galleryDiagram(template) });
|
|
1739
|
+
return sendScreen(response, problem !== null && preview === null && notice !== null ? 400 : 200, screen(template.name, `<p><a href="/flows/new">New flow</a></p><h1>${escape(template.name)}</h1>${html}`, { chrome: chromeFor(repo, "flows") }));
|
|
1740
|
+
}
|
|
1620
1741
|
async function handleGet(url, who, request, response) {
|
|
1621
1742
|
const now = clock();
|
|
1622
1743
|
let project = projectOf(who, request);
|
|
@@ -1716,7 +1837,9 @@ export function createDecisionServer(options) {
|
|
|
1716
1837
|
url.pathname !== "/menu" &&
|
|
1717
1838
|
url.pathname !== "/recipes" &&
|
|
1718
1839
|
// A flow names its own project; the list spans every project.
|
|
1719
|
-
url.pathname !== "/flows" && !/^\/flows\/[0-9]{1,15}(\/insights|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
|
|
1840
|
+
url.pathname !== "/flows" && url.pathname !== "/flows/import" && !/^\/flows\/[0-9]{1,15}(\/insights|\/export|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
|
|
1841
|
+
// The gallery (Flows → New) asks for its project on each template's page.
|
|
1842
|
+
url.pathname !== "/flows/new" && !/^\/flows\/new\/[a-z-]{1,40}$/.test(url.pathname) &&
|
|
1720
1843
|
// Teammates (v92) name their own project, like flows.
|
|
1721
1844
|
!url.pathname.startsWith("/teammates") &&
|
|
1722
1845
|
// New work names its project in the form (a dropdown of known
|
|
@@ -1729,6 +1852,7 @@ export function createDecisionServer(options) {
|
|
|
1729
1852
|
url.pathname !== "/projects/browse" && url.pathname !== "/projects/github" && url.pathname !== "/workbench" &&
|
|
1730
1853
|
url.pathname !== "/fleet" &&
|
|
1731
1854
|
url.pathname !== "/chat" && url.pathname !== "/chat/mate/status" && url.pathname !== "/chat/task-status" && url.pathname !== "/chat/stream" &&
|
|
1855
|
+
url.pathname !== "/lead/status" && url.pathname !== "/onboarding/phone/dismiss" &&
|
|
1732
1856
|
!url.pathname.startsWith("/chat/demo/") &&
|
|
1733
1857
|
!/^\/chat\/action\/[0-9]{1,15}$/.test(url.pathname) &&
|
|
1734
1858
|
!url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
|
|
@@ -1881,7 +2005,7 @@ export function createDecisionServer(options) {
|
|
|
1881
2005
|
const cancelled = store
|
|
1882
2006
|
.listCancelledBlockersScoped(project, 10, admission)
|
|
1883
2007
|
.filter(one => visible(one.repo) && visible(one.blockerRepo));
|
|
1884
|
-
|
|
2008
|
+
const inboxData = {
|
|
1885
2009
|
csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
1886
2010
|
revision: who.via === "cookie" ? who.session.projectRevision : 0,
|
|
1887
2011
|
rollup,
|
|
@@ -1923,7 +2047,26 @@ export function createDecisionServer(options) {
|
|
|
1923
2047
|
return { answering: answering.length, registered: runners.length, lastHeard };
|
|
1924
2048
|
})(),
|
|
1925
2049
|
now,
|
|
1926
|
-
|
|
2050
|
+
// Console v2: results ready to review and work running now, from the same admitted work index.
|
|
2051
|
+
...(() => {
|
|
2052
|
+
const row = (one) => ({ taskId: one.rootId, title: one.title, detail: one.status.detail, repo: one.repo });
|
|
2053
|
+
const needsYou = workIndexPage(store, now, workAccess(), { view: "needs-you", limit: 40, project });
|
|
2054
|
+
const runningNow = workIndexPage(store, now, workAccess(), { view: "running", limit: 40, project });
|
|
2055
|
+
return { ready: needsYou.items.filter(one => one.assignmentState === "ready-to-check").map(row), running: runningNow.items.map(row) };
|
|
2056
|
+
})(),
|
|
2057
|
+
};
|
|
2058
|
+
// Which tabs hold something new since this browser last looked (the dots on a phone).
|
|
2059
|
+
const tab = parseInboxTab(url.searchParams.get("tab")) ?? "all";
|
|
2060
|
+
const prints = inboxFingerprints(inboxData);
|
|
2061
|
+
const seenWords = /(?:^|;\s*)so-inbox-seen=([0-9a-f]{8}(?:\.[0-9a-f]{8}){3})(?:;|$)/.exec(request.headers.cookie ?? "")?.[1]?.split(".") ?? null;
|
|
2062
|
+
const order = ["needs-you", "ready", "running", "all"];
|
|
2063
|
+
const seen = Object.fromEntries(order.map((one, index) => [one, seenWords?.[index] ?? ""]));
|
|
2064
|
+
const unread = order.filter(one => seenWords !== null && seen[one] !== prints[one]);
|
|
2065
|
+
for (const one of order)
|
|
2066
|
+
if (tab === "all" || one === tab)
|
|
2067
|
+
seen[one] = prints[one];
|
|
2068
|
+
response.setHeader("Set-Cookie", `so-inbox-seen=${order.map(one => seen[one] || prints[one]).join(".")}; SameSite=Lax; Path=/; Max-Age=31536000`);
|
|
2069
|
+
return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), { ...inboxData, tab, unread }));
|
|
1927
2070
|
}
|
|
1928
2071
|
if (url.pathname === "/work") {
|
|
1929
2072
|
// The Work destination (workspace package 1): every task in view as
|
|
@@ -2598,7 +2741,7 @@ export function createDecisionServer(options) {
|
|
|
2598
2741
|
}
|
|
2599
2742
|
: null;
|
|
2600
2743
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
2601
|
-
return sendScreen(response, 200, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project, wanted === null ? undefined : wanted).slice(0, 200), wanted, csrf, null, project, prefill, store.permissionDefault().mode, store.qualityDefault().mode));
|
|
2744
|
+
return sendScreen(response, 200, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project, wanted === null ? undefined : wanted).slice(0, 200), wanted, csrf, null, project, prefill, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
2602
2745
|
}
|
|
2603
2746
|
if (url.pathname === "/queue") {
|
|
2604
2747
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
@@ -2942,10 +3085,31 @@ export function createDecisionServer(options) {
|
|
|
2942
3085
|
}
|
|
2943
3086
|
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), store.listCapabilities(project), computeGaps(store, project, now), project, now));
|
|
2944
3087
|
}
|
|
3088
|
+
// Flows → New: the gallery, and each template's page.
|
|
3089
|
+
if (url.pathname === "/flows/new") {
|
|
3090
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
3091
|
+
const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
|
|
3092
|
+
const repo = url.searchParams.get("repo");
|
|
3093
|
+
return sendScreen(response, 200, screen("New flow", `<p><a href="/flows">Flows</a></p><h1>New flow</h1>${galleryHtml({ repo: repo !== null && projects.includes(repo) ? repo : null, canUse })}`, { chrome: chromeFor(project, "flows") }));
|
|
3094
|
+
}
|
|
3095
|
+
const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
|
|
3096
|
+
if (galleryRead !== null) {
|
|
3097
|
+
const template = galleryTemplateOf(galleryRead[1]);
|
|
3098
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
3099
|
+
if (template === null)
|
|
3100
|
+
return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
|
|
3101
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
3102
|
+
return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
|
|
3103
|
+
if (projects.length === 0)
|
|
3104
|
+
return refuse(response, who, 409, "Add a project first.", "/projects");
|
|
3105
|
+
const asked = url.searchParams.get("repo");
|
|
3106
|
+
const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : projects[0];
|
|
3107
|
+
return sendGalleryUse(response, who, template, projects, repo, null, template.name, null);
|
|
3108
|
+
}
|
|
2945
3109
|
if (url.pathname === "/flows") {
|
|
2946
3110
|
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
2947
3111
|
const flows = store.listFlows(projects);
|
|
2948
|
-
return sendScreen(response, 200, screen("Flows", `<h1>Flows</h1>${flowsListHtml(store, flows, projects, who.via === "cookie" ? who.session.csrf : "", who.via === "cookie" && who.role === "approver", url.searchParams.get("problem"))}`, { chrome: chromeFor(project, "flows") }));
|
|
3112
|
+
return sendScreen(response, 200, screen("Flows", `<h1>Flows</h1>${flowsListHtml(store, flows, projects, who.via === "cookie" ? who.session.csrf : "", who.via === "cookie" && who.role === "approver", url.searchParams.get("problem"))}`, { chrome: chromeFor(project, "flows"), functional: { script: FLOW_IMPORT_SCRIPT } }));
|
|
2949
3113
|
}
|
|
2950
3114
|
// starter kits — the gallery, and each kit's checklist in a project.
|
|
2951
3115
|
if (url.pathname === "/kits" || /^\/kits\/[a-z-]{1,40}$/.test(url.pathname)) {
|
|
@@ -2991,6 +3155,22 @@ export function createDecisionServer(options) {
|
|
|
2991
3155
|
return json(404, { said: "No such run." });
|
|
2992
3156
|
return json(200, { card: run.card, entry: run.entry, script: run.script, version: run.scriptVersion, state: run.state, result: run.result, exitCode: run.exitCode, durationMs: run.durationMs, at: run.finishedAt ?? run.startedAt, log: run.log ?? "" });
|
|
2993
3157
|
}
|
|
3158
|
+
const flowExport = /^\/flows\/([1-9][0-9]{0,9})\/export$/.exec(url.pathname);
|
|
3159
|
+
if (flowExport !== null) {
|
|
3160
|
+
// A flow as a file (flow-share.ts): zones, paths, trigger settings and scripts; never secrets, addresses, names or cards.
|
|
3161
|
+
const flow = store.getFlow(Number(flowExport[1]));
|
|
3162
|
+
if (flow === null || flow.state !== "active" || !visible(flow.repo))
|
|
3163
|
+
return refuse(response, who, 404, "No such flow in your projects.", "/flows");
|
|
3164
|
+
let exported;
|
|
3165
|
+
try {
|
|
3166
|
+
exported = exportFlow(store, flow, options.configDir ?? null);
|
|
3167
|
+
}
|
|
3168
|
+
catch (error) {
|
|
3169
|
+
return refuse(response, who, 409, error instanceof Error ? error.message : "This flow can't be exported.", `/flows/${flow.id}`);
|
|
3170
|
+
}
|
|
3171
|
+
response.setHeader("Content-Disposition", `attachment; filename="${exported.fileName}"`);
|
|
3172
|
+
return respond(response, 200, "application/json; charset=utf-8", exported.json);
|
|
3173
|
+
}
|
|
2994
3174
|
const flowLive = /^\/flows\/([1-9][0-9]{0,9})\/live$/.exec(url.pathname);
|
|
2995
3175
|
if (flowLive !== null) {
|
|
2996
3176
|
// The live canvas (v88): a nudge the moment the flow changes, and who
|
|
@@ -3427,7 +3607,11 @@ export function createDecisionServer(options) {
|
|
|
3427
3607
|
}
|
|
3428
3608
|
// The first run belongs to the lead conversation, for someone who can act on it.
|
|
3429
3609
|
const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
|
|
3430
|
-
const
|
|
3610
|
+
const phone = focusTask === null && who.via === "cookie" ? phoneCard(who, now) : undefined;
|
|
3611
|
+
// Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
|
|
3612
|
+
const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
|
|
3613
|
+
const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
|
|
3614
|
+
: { ...shown, workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
|
|
3431
3615
|
if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
|
|
3432
3616
|
{
|
|
3433
3617
|
const said = takeMateNote(who.session.csrf, mateSession.id);
|
|
@@ -3454,6 +3638,7 @@ export function createDecisionServer(options) {
|
|
|
3454
3638
|
}
|
|
3455
3639
|
return sendScreen(response, 200, withFirstRun(chatPage(chromeFor(null, "chat", undefined, "all"), {
|
|
3456
3640
|
settingsOpen: settingsView,
|
|
3641
|
+
firstRunShown: firstRun !== undefined,
|
|
3457
3642
|
enabled,
|
|
3458
3643
|
pending,
|
|
3459
3644
|
latched,
|
|
@@ -3676,7 +3861,9 @@ export function createDecisionServer(options) {
|
|
|
3676
3861
|
repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
3677
3862
|
canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
|
|
3678
3863
|
said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
|
|
3679
|
-
})
|
|
3864
|
+
}) +
|
|
3865
|
+
// Every template, beside the starters: Use this opens its page for this project.
|
|
3866
|
+
`<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo() })}`;
|
|
3680
3867
|
return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
3681
3868
|
}
|
|
3682
3869
|
// Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
|
|
@@ -3712,7 +3899,7 @@ export function createDecisionServer(options) {
|
|
|
3712
3899
|
if (url.pathname === "/settings/retention") {
|
|
3713
3900
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
3714
3901
|
return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
|
|
3715
|
-
const view = { periods: store.retentionPeriods(), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
|
|
3902
|
+
const view = { periods: store.retentionPeriods(), chosen: Object.keys(store.retentionChosen()), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
|
|
3716
3903
|
return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
3717
3904
|
}
|
|
3718
3905
|
// Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
|
|
@@ -3738,6 +3925,19 @@ export function createDecisionServer(options) {
|
|
|
3738
3925
|
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
3739
3926
|
return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
3740
3927
|
}
|
|
3928
|
+
// Settings → Projects → Checks: how much checks after each build, and the quick command beside the full one.
|
|
3929
|
+
if (url.pathname === "/settings/checks") {
|
|
3930
|
+
const repo = url.searchParams.get("repo") ?? "";
|
|
3931
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
3932
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
3933
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
3934
|
+
const quick = liveQuickCommand(store, repo);
|
|
3935
|
+
const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
|
|
3936
|
+
level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
|
|
3937
|
+
review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())),
|
|
3938
|
+
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
3939
|
+
return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
3940
|
+
}
|
|
3741
3941
|
// Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
|
|
3742
3942
|
if (url.pathname === "/settings/backups") {
|
|
3743
3943
|
const databaseFile = store.databaseFile();
|
|
@@ -3831,6 +4031,27 @@ export function createDecisionServer(options) {
|
|
|
3831
4031
|
}
|
|
3832
4032
|
return sendScreen(response, 200, screen("Learning", `<p><a href="/settings">Settings</a></p><h1>Learning</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
3833
4033
|
}
|
|
4034
|
+
// Settings → Lead (onboarding): what runs the lead in one line, and the full form under Advanced.
|
|
4035
|
+
if (url.pathname === "/settings/lead") {
|
|
4036
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
4037
|
+
return refuse(response, who, 403, "An approver sets up the lead.", "/settings");
|
|
4038
|
+
if (localSignIn === null)
|
|
4039
|
+
await Promise.race([checkLocalAgents(), new Promise(done => setTimeout(done, 6_000).unref?.())]);
|
|
4040
|
+
const config = store.getChatConfig();
|
|
4041
|
+
const facts = {
|
|
4042
|
+
keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
|
|
4043
|
+
const found = chatKeyFor(one);
|
|
4044
|
+
return { provider: one, state: found === null ? "none" : found.source, tail: found === null || found.source === "environment" ? null : redactToken(found.key) };
|
|
4045
|
+
}),
|
|
4046
|
+
openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
|
|
4047
|
+
liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
|
|
4048
|
+
csrf: who.session.csrf,
|
|
4049
|
+
returnTo: "/settings/lead",
|
|
4050
|
+
};
|
|
4051
|
+
const signedIn = localSignIn?.states.claude === "connected" ? "Claude Code" : localSignIn?.states.codex === "connected" ? "Codex" : null;
|
|
4052
|
+
return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
|
|
4053
|
+
said: url.searchParams.get("said") }), { chrome: chromeFor(project, "settings") }));
|
|
4054
|
+
}
|
|
3834
4055
|
if (url.pathname === "/settings/telegram") {
|
|
3835
4056
|
// Any approver pairs their OWN phone here; the bot token stays on /settings.
|
|
3836
4057
|
if (who.via !== "cookie" || who.role !== "approver")
|
|
@@ -3914,7 +4135,7 @@ export function createDecisionServer(options) {
|
|
|
3914
4135
|
return at === null || since === null ? null : firstResultWords(since, at);
|
|
3915
4136
|
})(), (() => {
|
|
3916
4137
|
const chosen = store.notificationPreference(who.name);
|
|
3917
|
-
return { mode: chosen.mode, digestAt: chosen.digestAt };
|
|
4138
|
+
return { mode: chosen.mode, digestAt: chosen.digestAt, projects: notificationProjects(store, who.name) };
|
|
3918
4139
|
})()));
|
|
3919
4140
|
}
|
|
3920
4141
|
if (url.pathname === "/push/key") {
|
|
@@ -3939,6 +4160,18 @@ export function createDecisionServer(options) {
|
|
|
3939
4160
|
const back = decisionReturn === null ? null : safeChatReturn(decisionReturn);
|
|
3940
4161
|
return sendScreen(response, 200, decisionPage(chromeFor(project, "none"), decision, taskId, store.evidenceFor(decision.id), who, now, back));
|
|
3941
4162
|
}
|
|
4163
|
+
// While no agent is signed in, Chat asks this computer again on its own (onboarding): a fresh, non-spending sign-in
|
|
4164
|
+
// check at most every few seconds, and the lead turns on as soon as one is found.
|
|
4165
|
+
if (url.pathname === "/lead/status") {
|
|
4166
|
+
if (who.via !== "cookie")
|
|
4167
|
+
return respond(response, 403, "application/json", JSON.stringify({ error: "sign in" }));
|
|
4168
|
+
if (Date.now() - leadRecheckedAt > 4_000) {
|
|
4169
|
+
leadRecheckedAt = Date.now();
|
|
4170
|
+
await Promise.race([checkLocalAgents(true), new Promise(done => setTimeout(done, 8_000).unref?.())]);
|
|
4171
|
+
}
|
|
4172
|
+
response.setHeader("cache-control", "no-store");
|
|
4173
|
+
return respond(response, 200, "application/json", JSON.stringify({ lead: store.getChatConfig() === null ? "off" : "on", agent: localSignIn?.signedIn ?? null, command: agentSignInCommand() }));
|
|
4174
|
+
}
|
|
3942
4175
|
if (url.pathname === "/chat/task-status") {
|
|
3943
4176
|
if (who.via !== "cookie")
|
|
3944
4177
|
return respond(response, 403, "text/plain; charset=utf-8", "sign in to see this task");
|
|
@@ -3964,7 +4197,7 @@ export function createDecisionServer(options) {
|
|
|
3964
4197
|
function firstRunStepsNow(now) {
|
|
3965
4198
|
if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
|
|
3966
4199
|
return null;
|
|
3967
|
-
return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
|
|
4200
|
+
return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null, signInCommand: agentSignInCommand() });
|
|
3968
4201
|
}
|
|
3969
4202
|
/**
|
|
3970
4203
|
* Whether a coding agent is signed in where the work runs. This machine's
|
|
@@ -3984,17 +4217,54 @@ export function createDecisionServer(options) {
|
|
|
3984
4217
|
return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
|
|
3985
4218
|
}
|
|
3986
4219
|
let localSignIn = null;
|
|
3987
|
-
let localSignInChecking =
|
|
4220
|
+
let localSignInChecking = null;
|
|
4221
|
+
let leadRecheckedAt = 0;
|
|
4222
|
+
/** Ask this computer's agent CLIs again (no model, nothing spent); `fresh` skips their 30-second cache. */
|
|
4223
|
+
function checkLocalAgents(fresh = false) {
|
|
4224
|
+
if (localSignInChecking !== null)
|
|
4225
|
+
return localSignInChecking;
|
|
4226
|
+
localSignInChecking = Promise.all(["claude", "codex"].map(one => connectionCheck(one, fresh).then(value => value.state, () => "unverified")))
|
|
4227
|
+
.then(([claude, codex]) => {
|
|
4228
|
+
localSignIn = { at: Date.now(), signedIn: [claude, codex].some(state => state === "connected" || state === "key-works" || state === "key-present"), states: { claude: claude, codex: codex } };
|
|
4229
|
+
leadByDefault();
|
|
4230
|
+
})
|
|
4231
|
+
.catch(() => { })
|
|
4232
|
+
.finally(() => { localSignInChecking = null; });
|
|
4233
|
+
return localSignInChecking;
|
|
4234
|
+
}
|
|
3988
4235
|
function localAgentSignedIn() {
|
|
3989
|
-
if (
|
|
3990
|
-
|
|
3991
|
-
void Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")))
|
|
3992
|
-
.then(states => { localSignIn = { at: Date.now(), signedIn: states.some(state => state === "connected" || state === "key-works" || state === "key-present") }; })
|
|
3993
|
-
.catch(() => { })
|
|
3994
|
-
.finally(() => { localSignInChecking = false; });
|
|
3995
|
-
}
|
|
4236
|
+
if (localSignInChecking === null && (localSignIn === null || Date.now() - localSignIn.at > 30_000))
|
|
4237
|
+
void checkLocalAgents();
|
|
3996
4238
|
return localSignIn?.signedIn ?? null;
|
|
3997
4239
|
}
|
|
4240
|
+
/**
|
|
4241
|
+
* The lead on by default (onboarding): with no lead set up yet, the agent CLI signed in on this computer runs it
|
|
4242
|
+
* (Claude Code first, else Codex) on its membership with today's safe defaults: the CLI's own default model, 50 turns
|
|
4243
|
+
* a day, no dollar spend, and every action it proposes still a card a person confirms. Once only, ever: a lead
|
|
4244
|
+
* someone turned off stays off, and Settings → Lead changes it.
|
|
4245
|
+
*/
|
|
4246
|
+
function leadByDefault() {
|
|
4247
|
+
if (options.leadByDefault !== true || store.isDemo() || store.getChatConfig() !== null || store.installationFact(LEAD_BY_DEFAULT_FACT) !== null)
|
|
4248
|
+
return;
|
|
4249
|
+
const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
|
|
4250
|
+
if (provider === null)
|
|
4251
|
+
return;
|
|
4252
|
+
const now = clock();
|
|
4253
|
+
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
|
|
4254
|
+
store.recordInstallationFact(LEAD_BY_DEFAULT_FACT, provider, now);
|
|
4255
|
+
}
|
|
4256
|
+
// Look once at start, so the lead is on by the time the browser opens.
|
|
4257
|
+
if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
|
|
4258
|
+
void checkLocalAgents();
|
|
4259
|
+
/** What the lead runs on, in words for one line ("your Claude Code sign-in"); null for a direct API or no lead. */
|
|
4260
|
+
function leadWords() {
|
|
4261
|
+
const config = store.getChatConfig();
|
|
4262
|
+
return config?.provider === "claude-subscription" ? "your Claude Code sign-in" : config?.provider === "codex-subscription" ? "your Codex sign-in" : null;
|
|
4263
|
+
}
|
|
4264
|
+
/** The one command that gets an agent signed in here, for this operating system: sign in when its CLI is installed, else install Claude Code and sign in. */
|
|
4265
|
+
function agentSignInCommand() {
|
|
4266
|
+
return signInCommandFor(localSignIn?.states ?? null, options.platform ?? process.platform);
|
|
4267
|
+
}
|
|
3998
4268
|
/** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
|
|
3999
4269
|
* every ten minutes. A render never waits on `gh` or `git grep`. */
|
|
4000
4270
|
const firstTasks = new Map();
|
|
@@ -4018,10 +4288,43 @@ export function createDecisionServer(options) {
|
|
|
4018
4288
|
return undefined;
|
|
4019
4289
|
const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
|
|
4020
4290
|
const repo = project ?? managedRepos()[0] ?? null;
|
|
4291
|
+
const agent = steps.find(one => one.key === "agent");
|
|
4292
|
+
const lead = leadWords();
|
|
4021
4293
|
return {
|
|
4022
4294
|
steps,
|
|
4023
4295
|
suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
|
|
4024
|
-
sandbox:
|
|
4296
|
+
sandbox: agent !== undefined && !agent.done && !agent.checking ? SANDBOX_COMMAND : null,
|
|
4297
|
+
intro: HOW_IT_WORKS,
|
|
4298
|
+
lead: lead === null ? null : { words: `The lead uses ${lead}`, href: "/settings/lead" },
|
|
4299
|
+
// With no lead yet and no agent signed in, the page asks this computer again on its own.
|
|
4300
|
+
recheck: store.getChatConfig() === null && agent !== undefined && !agent.done ? "/lead/status" : null,
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
/** The first task's way to Ready shows until the first Ready result, and stays on the installation's first task. */
|
|
4304
|
+
function guided(rootId, now) {
|
|
4305
|
+
if (store.isDemo())
|
|
4306
|
+
return false;
|
|
4307
|
+
if (store.firstSuccessAt(now) === null)
|
|
4308
|
+
return true;
|
|
4309
|
+
const first = store.handle.prepare("SELECT id FROM task ORDER BY created_at, rowid LIMIT 1").get();
|
|
4310
|
+
return first !== undefined && String(first["id"]) === rootId;
|
|
4311
|
+
}
|
|
4312
|
+
/** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
|
|
4313
|
+
function phoneCard(who, now) {
|
|
4314
|
+
if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
|
|
4315
|
+
return undefined;
|
|
4316
|
+
const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
|
|
4317
|
+
if (botId !== null && store.liveTelegramBindings(botId).length > 0)
|
|
4318
|
+
return undefined;
|
|
4319
|
+
const port = servedPort() ?? 4180;
|
|
4320
|
+
const address = server.address();
|
|
4321
|
+
const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
|
|
4322
|
+
const name = tailnet[0] ?? null;
|
|
4323
|
+
const reachable = bound !== "127.0.0.1" && bound !== "::1";
|
|
4324
|
+
return {
|
|
4325
|
+
chatApps: [{ label: "Telegram", href: "/settings/telegram" }, { label: "Slack", href: "/settings/slack" }, { label: "Discord", href: "/settings/discord" }, { label: "Teams", href: "/settings/teams" }],
|
|
4326
|
+
tailnet: name === null ? null : { address: `http://${name}:${port}/`, restart: reachable ? null : consoleCommand({ host: "0.0.0.0" }) },
|
|
4327
|
+
dismissHref: "/onboarding/phone/dismiss",
|
|
4025
4328
|
};
|
|
4026
4329
|
}
|
|
4027
4330
|
// ---- fleet chat (v13) ----------------------------------------------------
|
|
@@ -4219,7 +4522,7 @@ export function createDecisionServer(options) {
|
|
|
4219
4522
|
return { ok: false, code: "empty", why: "add a project first — chat will include it automatically" };
|
|
4220
4523
|
const config = store.getChatConfig();
|
|
4221
4524
|
if (config === null)
|
|
4222
|
-
return { ok: false, code: "unconfigured", why: "
|
|
4525
|
+
return { ok: false, code: "unconfigured", why: "the lead is off — turn it on in Settings → Lead, or from the terminal: toolroll config set chat" };
|
|
4223
4526
|
if (isSubscriptionChatProvider(config.provider)) {
|
|
4224
4527
|
return {
|
|
4225
4528
|
ok: true,
|
|
@@ -4643,6 +4946,8 @@ export function createDecisionServer(options) {
|
|
|
4643
4946
|
...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
|
|
4644
4947
|
...(s.chrome.update === undefined ? {} : { update: s.chrome.update }),
|
|
4645
4948
|
...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
|
|
4949
|
+
...(extras.phone === undefined ? {} : { phone: extras.phone }),
|
|
4950
|
+
...(extras.home == null ? {} : { home: extras.home }),
|
|
4646
4951
|
};
|
|
4647
4952
|
if (requestFacts.workspaceRead) {
|
|
4648
4953
|
const validator = requestFacts.workspaceValidator;
|
|
@@ -4696,6 +5001,38 @@ export function createDecisionServer(options) {
|
|
|
4696
5001
|
* package 1): Work's own Needs-you membership, cached per viewer and
|
|
4697
5002
|
* project, so the rail, the phone tab, the scope bar, the project cards,
|
|
4698
5003
|
* the chat overview, and the Needs-you tab never disagree. */
|
|
5004
|
+
/** The Chat landing's live view: each agent at work now with its task and
|
|
5005
|
+
* phase, four counts, plan-window use (never dollars: subscriptions don't
|
|
5006
|
+
* bill per run) and Catch up's items, each tagged with its tab. Reads only. */
|
|
5007
|
+
function chatHomeOf(who, repos, now) {
|
|
5008
|
+
const access = { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) };
|
|
5009
|
+
const admitted = (repo) => repo === null ? visible(null) : repos.includes(repo);
|
|
5010
|
+
const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
|
|
5011
|
+
const root = familyOf(run.taskId)?.root ?? null;
|
|
5012
|
+
return { runId: run.id, taskId: root?.id ?? run.taskId, title: root?.title ?? run.title, href: taskHref(root?.id ?? run.taskId),
|
|
5013
|
+
agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt };
|
|
5014
|
+
});
|
|
5015
|
+
const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
|
|
5016
|
+
const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
|
|
5017
|
+
const done = workIndexPage(store, now, access, { view: "completed", limit: WORK_INDEX_MAX_LIMIT });
|
|
5018
|
+
const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
|
|
5019
|
+
const counts = [
|
|
5020
|
+
{ key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
|
|
5021
|
+
{ key: "waiting", label: "Waiting on you", value: needs.items.filter(one => one.assignmentState === "needs-decision").length, href: "/work?view=needs-you" },
|
|
5022
|
+
{ key: "ready", label: "Ready to review", value: needs.items.filter(one => one.assignmentState === "ready-to-check").length, href: "/work?view=needs-you" },
|
|
5023
|
+
{ key: "done", label: "Done this week", value: done.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo).length, href: "/work?view=completed" },
|
|
5024
|
+
];
|
|
5025
|
+
const tabOf = (state) => state === "needs-decision" ? "needs-you" : state === "ready-to-check" ? "ready" : state === "working" || state === "checking" ? "running" : "finished";
|
|
5026
|
+
const catchUp = all.items.filter(one => tabOf(one.assignmentState) !== "finished" || one.updatedAt >= weekAgo).slice(0, 40).map(one => ({
|
|
5027
|
+
id: one.rootId, title: one.title, href: taskHref(one.rootId), project: one.repo === null ? null : projectName(one.repo), tab: tabOf(one.assignmentState),
|
|
5028
|
+
label: one.status.label, tone: one.status.tone, detail: one.status.detail, at: one.updatedAt,
|
|
5029
|
+
}));
|
|
5030
|
+
const windows = who.via === "cookie" && store.isInstanceOperator(who.name) ? limitsView(store.providerLimits(), [], { project: projectName, teammate: id => `Teammate ${id}` }, now) : null;
|
|
5031
|
+
return {
|
|
5032
|
+
agents, counts, catchUp, allHref: "/work",
|
|
5033
|
+
planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
|
|
5034
|
+
};
|
|
5035
|
+
}
|
|
4699
5036
|
function needsYouBadge(project) {
|
|
4700
5037
|
const actor = requestContext.getStore()?.actor;
|
|
4701
5038
|
const key = `${actor ?? ""}:${actor === undefined ? "" : store.accountOf(actor)?.generation}:${project ?? ""}`;
|
|
@@ -4852,7 +5189,7 @@ export function createDecisionServer(options) {
|
|
|
4852
5189
|
const reviewFactsFor = (runId) => reviewFactsOf(store.reviewRetryStateOf(runId), runner => {
|
|
4853
5190
|
const one = store.getRunner(runner)?.runner;
|
|
4854
5191
|
return one !== undefined && runnerAlive(one, clock());
|
|
4855
|
-
});
|
|
5192
|
+
}, buildReviewOf(store, runId)?.state === "pending");
|
|
4856
5193
|
/** Keep detailed receipt diagnostics; shared assignment reads own readiness. */
|
|
4857
5194
|
function freshAssignment(assignment, receipt) {
|
|
4858
5195
|
if (assignment === null)
|
|
@@ -4892,6 +5229,7 @@ export function createDecisionServer(options) {
|
|
|
4892
5229
|
verdict: verdict?.verdict ?? null,
|
|
4893
5230
|
reasons: verdict?.reasons ?? [],
|
|
4894
5231
|
accepted: store.proofAcceptance(latest.id) !== null,
|
|
5232
|
+
checkLevel: resultCheckLevel(store, latest.id, now),
|
|
4895
5233
|
review: reviewFactsFor(latest.id),
|
|
4896
5234
|
...(latest.outcome !== "no-change"
|
|
4897
5235
|
? {}
|
|
@@ -5019,7 +5357,7 @@ export function createDecisionServer(options) {
|
|
|
5019
5357
|
peeks[one.path] = peekOf(one.path);
|
|
5020
5358
|
for (const path of candidates)
|
|
5021
5359
|
peeks[path] = peekOf(path);
|
|
5022
|
-
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined));
|
|
5360
|
+
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo, who.via === "cookie" ? (path) => publishingOf(store, path).on : undefined, who.via === "cookie" ? (path) => projectCheckLevel(store, path).level : undefined));
|
|
5023
5361
|
}
|
|
5024
5362
|
/** The task screen, shared by the GET and by every refusal that re-renders it. */
|
|
5025
5363
|
/**
|
|
@@ -5142,6 +5480,7 @@ export function createDecisionServer(options) {
|
|
|
5142
5480
|
const assignment = freshAssignment(recordedAssignment, completion?.receipt ?? null);
|
|
5143
5481
|
const progressRun = runs.find(one => runIsLive(one))?.id ?? completion?.runId ?? null;
|
|
5144
5482
|
return {
|
|
5483
|
+
guide: guided(family?.root.id ?? taskId, now),
|
|
5145
5484
|
assignment,
|
|
5146
5485
|
checkProgress: progressRun === null ? null : store.checkProgress(progressRun),
|
|
5147
5486
|
task: found,
|
|
@@ -5433,6 +5772,7 @@ export function createDecisionServer(options) {
|
|
|
5433
5772
|
id: family.root.id,
|
|
5434
5773
|
executionId: task.id,
|
|
5435
5774
|
family,
|
|
5775
|
+
guide: guided(family.root.id, now),
|
|
5436
5776
|
history: familyHistory(family),
|
|
5437
5777
|
title: family.root.title,
|
|
5438
5778
|
state: task.state,
|
|
@@ -5524,6 +5864,12 @@ export function createDecisionServer(options) {
|
|
|
5524
5864
|
if (cancelDraft !== undefined)
|
|
5525
5865
|
presentedData.cancelDraft = cancelDraft;
|
|
5526
5866
|
presentedData.pullRequest = taskPullRequestOf(taskId, who);
|
|
5867
|
+
// The thread reads the whole family: the original, its revisions, and every attempt across them.
|
|
5868
|
+
if (family !== null && family.problem === null) {
|
|
5869
|
+
presentedData.family = { root: { id: family.root.id, title: family.root.title, createdAt: family.root.createdAt, goal: store.getScope(family.root.id)?.goal ?? null },
|
|
5870
|
+
versions: family.versions.map(one => ({ id: one.id, title: one.title, state: one.state })),
|
|
5871
|
+
runs: family.versions.flatMap(version => version.id === data.task.id ? data.runs.map(run => ({ ...run, taskId: version.id })) : store.runsFor(version.refId).map(run => ({ ...run, taskId: version.id }))) };
|
|
5872
|
+
}
|
|
5527
5873
|
const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
|
|
5528
5874
|
const page = taskPage(paneProject === null && !unscopedMode
|
|
5529
5875
|
? chromeFor(paneProject, "tasks")
|
|
@@ -5531,10 +5877,13 @@ export function createDecisionServer(options) {
|
|
|
5531
5877
|
// The task's own conversation, docked beside the page (v77).
|
|
5532
5878
|
const focus = taskChatFocus(family?.root.id ?? taskId, clock(), who, { mintNonce: false });
|
|
5533
5879
|
const docked = focus === null ? null : dockedConversation(who, focus, null, clock(), taskHref(focus.id));
|
|
5534
|
-
// The
|
|
5535
|
-
|
|
5880
|
+
// The conversation is the page's own thread here, so the page drops its Ask tab;
|
|
5881
|
+
// only an approver is offered a road to message the agent.
|
|
5882
|
+
const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }) } : page.workspace?.view;
|
|
5536
5883
|
if (docked !== null)
|
|
5537
5884
|
page.workspace = { ...page.workspace, ...(taskView === undefined ? {} : { view: taskView }), conversation: docked, pageHtml: page.body };
|
|
5885
|
+
else if (taskView !== undefined && page.workspace !== undefined)
|
|
5886
|
+
page.workspace = { ...page.workspace, view: taskView };
|
|
5538
5887
|
return sendScreen(response, status, page);
|
|
5539
5888
|
}
|
|
5540
5889
|
/** The routine screen: the standing order restated, its verbs, its ledger. */
|
|
@@ -6357,14 +6706,45 @@ export function createDecisionServer(options) {
|
|
|
6357
6706
|
return redirect(response, `/teammates?said=${encodeURIComponent(done.said)}`);
|
|
6358
6707
|
return redirect(response, `${back}?${done.ok ? "said" : "problem"}=${encodeURIComponent(done.said)}${action === "note" ? "#memory" : ""}`);
|
|
6359
6708
|
}
|
|
6709
|
+
// Flows → New → a template: preview these answers, or create exactly what was previewed.
|
|
6710
|
+
const galleryPost = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
|
|
6711
|
+
if (galleryPost !== null) {
|
|
6712
|
+
const template = galleryTemplateOf(galleryPost[1]);
|
|
6713
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
6714
|
+
if (template === null)
|
|
6715
|
+
return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
|
|
6716
|
+
if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
|
|
6717
|
+
return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
|
|
6718
|
+
const repo = body.get("repo") ?? "";
|
|
6719
|
+
if (!projects.includes(repo))
|
|
6720
|
+
return refuse(response, who, 404, "Choose one of your projects.", "/flows/new");
|
|
6721
|
+
const answers = Object.fromEntries(template.asks.map(ask => [ask.key, body.get(ask.key) ?? ""]));
|
|
6722
|
+
const name = (body.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || template.name;
|
|
6723
|
+
if (body.get("intent") !== "create")
|
|
6724
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, null);
|
|
6725
|
+
// What's made is what was previewed: changed answers are previewed again first.
|
|
6726
|
+
let digest = null;
|
|
6727
|
+
try {
|
|
6728
|
+
digest = previewGallery(store, template, repo, answers, who.name, clock()).digest;
|
|
6729
|
+
}
|
|
6730
|
+
catch {
|
|
6731
|
+
digest = null;
|
|
6732
|
+
}
|
|
6733
|
+
if (digest === null || digest !== body.get("previewed"))
|
|
6734
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, digest === null ? null : "Your answers changed. Check the preview, then create the flow.");
|
|
6735
|
+
const used = useGalleryTemplate(store, template, repo, answers, { name, by: who.name, now: clock(), dir: options.configDir ?? null });
|
|
6736
|
+
if (!used.ok)
|
|
6737
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, used.said);
|
|
6738
|
+
return redirect(response, `/flows/${used.flow}`);
|
|
6739
|
+
}
|
|
6360
6740
|
const flowPost = /^\/flows\/([1-9][0-9]{0,9})\/(save|cards|archive|triggers|linear-key|hooks-address|scripts|secrets)$/.exec(url.pathname) ?? /^\/flows\/([1-9][0-9]{0,9})\/cards\/([1-9][0-9]{0,9})\/(move|decide|cancel|comment|assign|watch)$/.exec(url.pathname);
|
|
6361
6741
|
const triggerPost = /^\/flows\/([1-9][0-9]{0,9})\/triggers\/([1-9][0-9]{0,9})\/(pause|resume|remove|check|press|renew|secret|share|unshare)$/.exec(url.pathname);
|
|
6362
|
-
if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || flowPost !== null || triggerPost !== null) {
|
|
6742
|
+
if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
|
|
6363
6743
|
const now = clock();
|
|
6364
6744
|
const answer = (status, payload) => respond(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
|
|
6365
6745
|
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
6366
6746
|
if (who.via !== "cookie" || who.role !== "approver")
|
|
6367
|
-
return url.pathname === "/flows/new" || url.pathname === "/flows/example" ? refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows") : answer(403, { ok: false, said: "Sign in as an approver to change flows." });
|
|
6747
|
+
return url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" ? refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows") : answer(403, { ok: false, said: "Sign in as an approver to change flows." });
|
|
6368
6748
|
if (url.pathname === "/flows/example") {
|
|
6369
6749
|
// A first look (v88): the Email replies template with one sample question, which Claude drafts a reply to straight away.
|
|
6370
6750
|
const repo = body.get("repo") ?? "";
|
|
@@ -6375,6 +6755,52 @@ export function createDecisionServer(options) {
|
|
|
6375
6755
|
addCardToFlow(store, store.getFlow(id), { title: "Do you ship to Canada?", description: "Hi! I'm thinking of ordering but I live in Toronto. Do you ship there, and how long does it take? — sam@example.com", stage: null }, who.name, now);
|
|
6376
6756
|
return redirect(response, `/flows/${id}`);
|
|
6377
6757
|
}
|
|
6758
|
+
if (url.pathname === "/flows/import") {
|
|
6759
|
+
// A flow file, chosen or fetched from a gist: previewed in plain words, then made with its triggers off and its scripts held.
|
|
6760
|
+
const repo = body.get("repo") ?? "";
|
|
6761
|
+
const back = (problem) => redirect(response, `/flows?problem=${encodeURIComponent(problem)}`);
|
|
6762
|
+
if (!projects.includes(repo))
|
|
6763
|
+
return back("Choose one of your projects.");
|
|
6764
|
+
let document = body.get("document") ?? "";
|
|
6765
|
+
const address = (body.get("url") ?? "").trim();
|
|
6766
|
+
let file;
|
|
6767
|
+
try {
|
|
6768
|
+
if (document.trim() === "" && address !== "")
|
|
6769
|
+
document = await fetchFlowFile(address, options.flowFetch);
|
|
6770
|
+
if (document.trim() === "")
|
|
6771
|
+
return back("Choose a flow file or give its address.");
|
|
6772
|
+
file = parseFlowFile(document);
|
|
6773
|
+
}
|
|
6774
|
+
catch (error) {
|
|
6775
|
+
if (error instanceof FlowFileError)
|
|
6776
|
+
return back(error.message);
|
|
6777
|
+
throw error;
|
|
6778
|
+
}
|
|
6779
|
+
const given = Object.fromEntries(file.parameters.flatMap(one => body.has(`param.${one.id}`) ? [[one.id, body.get(`param.${one.id}`) ?? ""]] : []));
|
|
6780
|
+
// What was previewed: the file and every value it resolved to (a default the first preview filled in included).
|
|
6781
|
+
const previewedOf = (plan) => createHash("sha256").update(`${document}\0${JSON.stringify(plan?.values ?? given)}`).digest("hex").slice(0, 32);
|
|
6782
|
+
const page = (plan, problem, status) => sendScreen(response, status, screen(`Import ${file.name}`, `<p><a href="/flows">Flows</a></p><h1>Import ${escape(file.name)}</h1>${flowImportHtml({ plan, file, document, repo, csrf: who.session.csrf, values: given, previewed: previewedOf(plan), problem })}`, { chrome: chromeFor(repo, "flows") }));
|
|
6783
|
+
let plan;
|
|
6784
|
+
try {
|
|
6785
|
+
plan = planFlowImport(store, repo, file, given, who.name);
|
|
6786
|
+
}
|
|
6787
|
+
catch (error) {
|
|
6788
|
+
if (error instanceof FlowFileError)
|
|
6789
|
+
return page(null, error.message, 400);
|
|
6790
|
+
throw error;
|
|
6791
|
+
}
|
|
6792
|
+
if (body.get("confirm") === "yes") {
|
|
6793
|
+
if (body.get("previewed") !== previewedOf(plan))
|
|
6794
|
+
return page(plan, "What you filled in changed. Check the preview again, then import.", 409);
|
|
6795
|
+
try {
|
|
6796
|
+
return redirect(response, `/flows/${importFlow(store, plan, who.name, now, options.configDir ?? null).id}`);
|
|
6797
|
+
}
|
|
6798
|
+
catch (error) {
|
|
6799
|
+
return page(plan, error instanceof Error ? error.message : "That flow couldn't be imported.", 400);
|
|
6800
|
+
}
|
|
6801
|
+
}
|
|
6802
|
+
return page(plan, null, 200);
|
|
6803
|
+
}
|
|
6378
6804
|
if (url.pathname === "/flows/new") {
|
|
6379
6805
|
const repo = body.get("repo") ?? "";
|
|
6380
6806
|
if (!projects.includes(repo))
|
|
@@ -6470,6 +6896,8 @@ export function createDecisionServer(options) {
|
|
|
6470
6896
|
}
|
|
6471
6897
|
if (action === "scripts") {
|
|
6472
6898
|
// The project's script library, reached from any of its flows.
|
|
6899
|
+
if (body.get("approve") === "yes")
|
|
6900
|
+
return store.approveFlowScript(flow.repo, body.get("name") ?? "") ? settle("Approved. Zones that run it go on.") : answer(404, { ok: false, said: "That script isn't waiting for approval." });
|
|
6473
6901
|
if (body.get("remove") === "yes")
|
|
6474
6902
|
return store.removeFlowScript(flow.repo, body.get("name") ?? "") ? settle("Script removed. Zones that ran it wait until it's back.") : answer(404, { ok: false, said: "There's no script by that name." });
|
|
6475
6903
|
const saved = saveScript(store, flow.repo, { name: body.get("name"), about: body.get("about"), body: body.get("body"), timeoutMinutes: body.get("timeoutMinutes"), language: body.get("language"), file: body.get("file") }, who.name, now);
|
|
@@ -6594,6 +7022,26 @@ export function createDecisionServer(options) {
|
|
|
6594
7022
|
return back("said", `${label}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`);
|
|
6595
7023
|
}
|
|
6596
7024
|
// Delete a project, in two steps: its name, then what goes and the password. An instance operator; the ledger keeps who and what.
|
|
7025
|
+
// Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
|
|
7026
|
+
if (url.pathname === "/settings/project/concurrency") {
|
|
7027
|
+
const repo = body.get("repo") ?? "";
|
|
7028
|
+
const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
7029
|
+
if (!known.includes(repo))
|
|
7030
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
7031
|
+
if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
|
|
7032
|
+
return refuse(response, who, 403, "An approver for this project sets how many tasks build at once.", "/settings/project");
|
|
7033
|
+
const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7034
|
+
const n = parseProjectConcurrency(body.get("concurrency") ?? "");
|
|
7035
|
+
if (n === null)
|
|
7036
|
+
return back("problem", "Builds at once is a whole number from 1 to 64. Nothing changed.");
|
|
7037
|
+
const databaseFile = store.databaseFile();
|
|
7038
|
+
if (databaseFile === null)
|
|
7039
|
+
return back("problem", "This database keeps no settings files. Nothing changed.");
|
|
7040
|
+
const changed = saveProjectConcurrency(databaseFile, repo, n);
|
|
7041
|
+
store.recordProjectConcurrency(who.name, repo, changed.before, changed.after, now);
|
|
7042
|
+
store.bumpWake();
|
|
7043
|
+
return back("said", changed.before === changed.after ? "Nothing changed." : `Saved. Up to ${n} at once.`);
|
|
7044
|
+
}
|
|
6597
7045
|
if (url.pathname === "/settings/project/delete") {
|
|
6598
7046
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
6599
7047
|
return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
|
|
@@ -6818,7 +7266,9 @@ export function createDecisionServer(options) {
|
|
|
6818
7266
|
if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
|
|
6819
7267
|
return back("problem", "Enter your Toolroll password to change retention.");
|
|
6820
7268
|
const before = store.retentionPeriods();
|
|
6821
|
-
|
|
7269
|
+
// Only what the page offers (or the period already set): a hand-made value doesn't save.
|
|
7270
|
+
const offered = (kind, days) => days !== undefined && periodChoices(kind, before[kind]).includes(days) ? days : undefined;
|
|
7271
|
+
const chosen = RETENTION_KINDS.map(({ kind }) => ({ kind, days: body.has(kind) ? offered(kind, parsePeriod(body.get(kind) ?? "")) : before[kind] }));
|
|
6822
7272
|
if (chosen.some(one => one.days === undefined))
|
|
6823
7273
|
return back("problem", "Choose a period for each kind of data.");
|
|
6824
7274
|
const changed = chosen.filter(one => one.days !== before[one.kind]);
|
|
@@ -6895,6 +7345,54 @@ export function createDecisionServer(options) {
|
|
|
6895
7345
|
}
|
|
6896
7346
|
return back("problem", "That change isn't available.");
|
|
6897
7347
|
}
|
|
7348
|
+
// Checks for one project: the level, or the quick command (approved like `verify set --quick`). Each takes
|
|
7349
|
+
// the password and lands in the ledger.
|
|
7350
|
+
if (url.pathname === "/settings/checks") {
|
|
7351
|
+
const repo = body.get("repo") ?? "";
|
|
7352
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
7353
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
7354
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
7355
|
+
if (who.role !== "approver" || store.isDemo())
|
|
7356
|
+
return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
|
|
7357
|
+
const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7358
|
+
if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
|
|
7359
|
+
return back("problem", "Enter your Toolroll password to change checks.");
|
|
7360
|
+
const act = body.get("act");
|
|
7361
|
+
// The automatic review switch: the policy log keeps before → after.
|
|
7362
|
+
if (act === "review") {
|
|
7363
|
+
const on = body.get("on") === "1";
|
|
7364
|
+
store.setReviewSwitch(repo, on, who.name, now);
|
|
7365
|
+
return back("said", on ? "Automatic review is on." : "Automatic review is off.");
|
|
7366
|
+
}
|
|
7367
|
+
if (act === "level") {
|
|
7368
|
+
const level = body.get("level");
|
|
7369
|
+
if (!isCheckLevel(level))
|
|
7370
|
+
return back("problem", "Choose Quick, Full or Off.");
|
|
7371
|
+
const changed = setProjectCheckLevel(store, repo, level, who.name, now);
|
|
7372
|
+
return back("said", changed.changed ? `Checks are ${CHECK_LEVEL_WORDS[level]}.` : "Saved.");
|
|
7373
|
+
}
|
|
7374
|
+
if (act === "quick") {
|
|
7375
|
+
const command = (body.get("command") ?? "").trim();
|
|
7376
|
+
const seconds = Number(body.get("timeout") ?? "180");
|
|
7377
|
+
if (command === "" || command.length > 2000 || hasDisguisedText(command))
|
|
7378
|
+
return back("problem", "Enter the quick command: one line of up to 2000 characters.");
|
|
7379
|
+
if (/([A-Za-z0-9_-]*(?:token|secret|password|passwd|apikey|api_key|authorization|bearer|credential)[A-Za-z0-9_-]*\s*[=:]\s*)(?![$"']?\$)\S+/i.test(command) || /\/\/[^\s/@]+:[^\s/@]+@/.test(command)) {
|
|
7380
|
+
return back("problem", "That command seems to hold a credential. Use an environment variable instead.");
|
|
7381
|
+
}
|
|
7382
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600)
|
|
7383
|
+
return back("problem", "Choose a time limit from 1 to 3600 seconds.");
|
|
7384
|
+
store.setVerifyCommand({ repo: quickVerifyKey(repo), command, timeoutMs: seconds * 1000, approvedBy: who.name }, now);
|
|
7385
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check approved", outcome: "approved", source: "policy", detail: command.slice(0, 200) });
|
|
7386
|
+
return back("said", "Quick check approved.");
|
|
7387
|
+
}
|
|
7388
|
+
if (act === "quick-clear") {
|
|
7389
|
+
const cleared = store.clearVerifyCommand(quickVerifyKey(repo), who.name, now);
|
|
7390
|
+
if (cleared)
|
|
7391
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check removed", outcome: "removed", source: "policy" });
|
|
7392
|
+
return back("said", cleared ? "Quick check removed. Quick builds run the full check." : "There was no quick check.");
|
|
7393
|
+
}
|
|
7394
|
+
return back("problem", "That change isn't available.");
|
|
7395
|
+
}
|
|
6898
7396
|
// Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
|
|
6899
7397
|
if (url.pathname === "/settings/backups/now") {
|
|
6900
7398
|
const databaseFile = store.databaseFile();
|
|
@@ -7543,6 +8041,16 @@ export function createDecisionServer(options) {
|
|
|
7543
8041
|
: after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
|
|
7544
8042
|
return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
|
|
7545
8043
|
}
|
|
8044
|
+
if (url.pathname === "/settings/notifications/mute") {
|
|
8045
|
+
// Each person's own pings: a muted project stays in Tasks and the evening digest.
|
|
8046
|
+
const repo = body.get("repo") ?? "";
|
|
8047
|
+
const project = notificationProjects(store, who.name).find(one => one.repo === repo);
|
|
8048
|
+
if (project === undefined)
|
|
8049
|
+
return refuse(response, who, 400, "that project isn't one you can see", "/settings");
|
|
8050
|
+
const muted = body.get("pings") !== "on";
|
|
8051
|
+
store.setProjectMuted(who.name, repo, muted, now);
|
|
8052
|
+
return redirect(response, `/settings?said=${encodeURIComponent(muted ? `${project.name} muted. It still shows in Tasks and your evening digest.` : `${project.name} pings you again.`)}#notifications`);
|
|
8053
|
+
}
|
|
7546
8054
|
if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
|
|
7547
8055
|
// The cadence is a closed list of minutes — never a free number from
|
|
7548
8056
|
// a form; "off" clears it. Any approver session may set it.
|
|
@@ -7750,7 +8258,7 @@ export function createDecisionServer(options) {
|
|
|
7750
8258
|
// under a ceiling. Unscoped mode keeps its historic unplaced filings.
|
|
7751
8259
|
if (!unscopedMode && effective === "") {
|
|
7752
8260
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7753
|
-
return sendScreen(response, 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, "name a repository — no project is open, so the task must say where it belongs", project, null, store.permissionDefault().mode, store.qualityDefault().mode));
|
|
8261
|
+
return sendScreen(response, 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, "name a repository — no project is open, so the task must say where it belongs", project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
7754
8262
|
}
|
|
7755
8263
|
let repo = effective;
|
|
7756
8264
|
let admitted = unscopedMode ? null : admissionList() ?? [];
|
|
@@ -7759,7 +8267,7 @@ export function createDecisionServer(options) {
|
|
|
7759
8267
|
const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
|
|
7760
8268
|
if (canonical === null || canonical === undefined) {
|
|
7761
8269
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7762
|
-
return sendScreen(response, 403, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, `${effective} is outside what this server was configured to show`, project, null, store.permissionDefault().mode, store.qualityDefault().mode));
|
|
8270
|
+
return sendScreen(response, 403, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, `${effective} is outside what this server was configured to show`, project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
7763
8271
|
}
|
|
7764
8272
|
repo = canonical;
|
|
7765
8273
|
admitted = [canonical];
|
|
@@ -7816,7 +8324,7 @@ export function createDecisionServer(options) {
|
|
|
7816
8324
|
});
|
|
7817
8325
|
if (!made.ok) {
|
|
7818
8326
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7819
|
-
return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body }, store.permissionDefault().mode, store.qualityDefault().mode));
|
|
8327
|
+
return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body }, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
7820
8328
|
}
|
|
7821
8329
|
const actionContext = requestContext.getStore();
|
|
7822
8330
|
if (actionContext !== undefined)
|
|
@@ -8291,7 +8799,7 @@ export function createDecisionServer(options) {
|
|
|
8291
8799
|
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
8292
8800
|
return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
|
|
8293
8801
|
}
|
|
8294
|
-
const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
|
|
8802
|
+
const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(body.get("anyway") === "1" ? { anyway: true } : {}), ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
|
|
8295
8803
|
if (!merged.ok)
|
|
8296
8804
|
return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
|
|
8297
8805
|
return redirect(response, `${taskHref(act.taskId)}#merge`);
|
|
@@ -8475,6 +8983,24 @@ export function createDecisionServer(options) {
|
|
|
8475
8983
|
store.retirePushSubscription(mine.id, `removed by ${who.name}`, clock());
|
|
8476
8984
|
return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
|
|
8477
8985
|
}
|
|
8986
|
+
if (url.pathname === "/onboarding/phone/dismiss") {
|
|
8987
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
8988
|
+
return refuse(response, who, 403, "An approver puts this away.", "/chat");
|
|
8989
|
+
store.recordInstallationFact(PHONE_CARD_FACT, who.name, now);
|
|
8990
|
+
return body.get("quiet") === "1" ? respond(response, 204, "text/plain; charset=utf-8", "") : redirect(response, "/chat");
|
|
8991
|
+
}
|
|
8992
|
+
// Turn the lead on with the agent signed in on this computer: its membership spends no dollars, so like starting a
|
|
8993
|
+
// conversation it needs no second password; the full form (Advanced) still asks for one.
|
|
8994
|
+
if (url.pathname === "/settings/lead/on") {
|
|
8995
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
8996
|
+
return refuse(response, who, 403, "An approver turns the lead on.", "/settings/lead");
|
|
8997
|
+
await checkLocalAgents(true);
|
|
8998
|
+
const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
|
|
8999
|
+
if (provider === null)
|
|
9000
|
+
return redirect(response, chatReturnWithSaid("/settings/lead", `No agent is signed in on this computer yet. Run ${agentSignInCommand()}, then try again.`));
|
|
9001
|
+
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
|
|
9002
|
+
return redirect(response, "/chat");
|
|
9003
|
+
}
|
|
8478
9004
|
if (url.pathname === "/chat/config") {
|
|
8479
9005
|
// The console's own door into `config set chat` (operator request:
|
|
8480
9006
|
// chat lives mainly in the web UI). Same ceremony weight as the CLI
|
|
@@ -8483,7 +9009,7 @@ export function createDecisionServer(options) {
|
|
|
8483
9009
|
// touches a form or this database — environment only.
|
|
8484
9010
|
if (who.via !== "cookie")
|
|
8485
9011
|
return refuse(response, who, 403, "chat setup is a browser surface");
|
|
8486
|
-
const back = safeChatReturn(body.get("return"));
|
|
9012
|
+
const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
|
|
8487
9013
|
const password = body.get("token") ?? "";
|
|
8488
9014
|
if (password === "" || !authenticateApprover(store, who.name, password).ok) {
|
|
8489
9015
|
return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
|
|
@@ -8749,6 +9275,14 @@ export function createDecisionServer(options) {
|
|
|
8749
9275
|
return said(409, "That result is no longer available for this task. Review the task before sending.");
|
|
8750
9276
|
}
|
|
8751
9277
|
const resultContext = viewedRun === null ? "" : ` The operator is viewing result #${viewedRun.id} from execution ${store.externalIdFor(viewedRun.taskRef)}. Use get_result for that exact execution and run when responding to feedback; do not substitute another result.`;
|
|
9278
|
+
// The task composer's mode (console v2): fixed words for this turn only.
|
|
9279
|
+
// It steers what the lead proposes and grants nothing: every action
|
|
9280
|
+
// still arrives as a card the person confirms under its own rules.
|
|
9281
|
+
const modeValue = body.get("mode");
|
|
9282
|
+
if (modeValue !== null && (focusTask === null || body.getAll("mode").length !== 1 || !Object.hasOwn(TASK_COMPOSER_MODES, modeValue))) {
|
|
9283
|
+
return said(400, "Choose Build, Plan only or Just answer, then send again. Your draft is saved.");
|
|
9284
|
+
}
|
|
9285
|
+
const modeContext = modeValue === null ? "" : ` ${TASK_COMPOSER_MODES[modeValue]}`;
|
|
8752
9286
|
const enabled = chatEnablement();
|
|
8753
9287
|
if (!enabled.ok)
|
|
8754
9288
|
return said(409, enabled.why);
|
|
@@ -8770,7 +9304,7 @@ export function createDecisionServer(options) {
|
|
|
8770
9304
|
}
|
|
8771
9305
|
const opened = store.openMateThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
|
|
8772
9306
|
const onProgress = beginLiveTurn(opened.thread.id);
|
|
8773
|
-
void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
|
|
9307
|
+
void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
|
|
8774
9308
|
.then(outcome => {
|
|
8775
9309
|
if (!outcome.ok)
|
|
8776
9310
|
noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
|
|
@@ -9280,6 +9814,33 @@ export function createDecisionServer(options) {
|
|
|
9280
9814
|
return refuse(response, who, result.status, result.message, back);
|
|
9281
9815
|
return redirect(response, revisionDestination(result.id, back));
|
|
9282
9816
|
}
|
|
9817
|
+
// Follow-ups on a result: Run checks on its exact commit (a worker runs the
|
|
9818
|
+
// approved command once and seals the log), or file a task to add tests.
|
|
9819
|
+
const followUp = /^\/r\/([0-9]{1,15})\/(checks|add-tests)$/.exec(url.pathname);
|
|
9820
|
+
if (followUp !== null) {
|
|
9821
|
+
const id = Number(followUp[1]);
|
|
9822
|
+
const found = store.getRun(id);
|
|
9823
|
+
if (found === null || !visible(taskRepoOf(found.taskRef)))
|
|
9824
|
+
return refuse(response, who, 404, "no such run");
|
|
9825
|
+
const back = resultReturnTarget(body.get("return"), id);
|
|
9826
|
+
if (who.via !== "cookie")
|
|
9827
|
+
return refuse(response, who, 403, "Sign in with a browser session to do this.", back);
|
|
9828
|
+
if (followUp[2] === "checks") {
|
|
9829
|
+
if (who.role !== "approver")
|
|
9830
|
+
return refuse(response, who, 403, "An approver runs checks.", back);
|
|
9831
|
+
if (store.isDemo())
|
|
9832
|
+
return refuse(response, who, 403, "The demo runs no checks.", back);
|
|
9833
|
+
const level = body.get("level") === "full" ? "full" : "quick";
|
|
9834
|
+
const asked = requestFollowUpChecks(store, { runId: id, level, actor: who.name }, now);
|
|
9835
|
+
if (!asked.ok)
|
|
9836
|
+
return refuse(response, who, 409, asked.message, back);
|
|
9837
|
+
return redirect(response, `${back.split("#")[0]}#follow-ups`);
|
|
9838
|
+
}
|
|
9839
|
+
const filed = fileAddTestsTask(store, evidenceRoot, { runId: id, actor: who.name, filedVia: "console", ...(admissionList() === null ? {} : { admittedRepos: admissionList() }) }, now);
|
|
9840
|
+
if (!filed.ok)
|
|
9841
|
+
return refuse(response, who, 409, filed.message, back);
|
|
9842
|
+
return redirect(response, taskHref(filed.id));
|
|
9843
|
+
}
|
|
9283
9844
|
const draftRepair = /^\/r\/([0-9]{1,15})\/draft-repair$/.exec(url.pathname);
|
|
9284
9845
|
if (draftRepair !== null) {
|
|
9285
9846
|
// CI repair, suggestion-first (M8.18): a red episode never spawns an
|
|
@@ -9953,7 +10514,7 @@ export function createDecisionServer(options) {
|
|
|
9953
10514
|
}
|
|
9954
10515
|
case "cancel": {
|
|
9955
10516
|
const reason = body.get("reason") ?? undefined;
|
|
9956
|
-
const cancelled = store.cancelTask(taskId, now, reason);
|
|
10517
|
+
const cancelled = withActor({ account: who.name, lead: false }, () => store.cancelTask(taskId, now, reason));
|
|
9957
10518
|
if (!cancelled.ok) {
|
|
9958
10519
|
if (cancelled.reason === "reason-required" || cancelled.reason === "bad-reason") {
|
|
9959
10520
|
return taskScreen(response, who, taskId, cancelled.reason === "reason-required"
|
|
@@ -10872,6 +11433,7 @@ export function createDecisionServer(options) {
|
|
|
10872
11433
|
comments: store.liveDiffComments(run.id).filter(isRevisionFeedback),
|
|
10873
11434
|
pastComments: store.allDiffComments(run.id).filter(one => one.reviewerRun === null && (one.consumedBy !== null || one.supersededBy !== null)),
|
|
10874
11435
|
reviewerFindings: store.allDiffComments(run.id).filter(one => one.reviewerRun !== null),
|
|
11436
|
+
automaticReview: buildReviewOf(store, run.id),
|
|
10875
11437
|
revisions: store.revisionsFromRun(run.id).flatMap(one => {
|
|
10876
11438
|
const child = store.getTask(one.id);
|
|
10877
11439
|
if (child === null || !visible(store.lookupRef(one.id)?.repo ?? null))
|
|
@@ -10881,6 +11443,7 @@ export function createDecisionServer(options) {
|
|
|
10881
11443
|
return [{ id: one.id, title: one.title, state: one.state, approved: approvalOf(store.getScope(one.id)).approved, standing: status.label, tone: status.tone }];
|
|
10882
11444
|
}),
|
|
10883
11445
|
sourceDigest: scope?.digest ?? null,
|
|
11446
|
+
followUps: followUpsFor(store, evidenceRoot, run, now),
|
|
10884
11447
|
route: store.runRoute(run.id),
|
|
10885
11448
|
// Editor links (arc 6): the deployment capability, THIS machine's
|
|
10886
11449
|
// runner owning the run, the session's own device-side yes, and a
|
|
@@ -11060,7 +11623,7 @@ export function createDecisionServer(options) {
|
|
|
11060
11623
|
});
|
|
11061
11624
|
return server;
|
|
11062
11625
|
});
|
|
11063
|
-
return server;
|
|
11626
|
+
return Object.assign(server, { mintSignInLink });
|
|
11064
11627
|
}
|
|
11065
11628
|
// ---- path plumbing ---------------------------------------------------------
|
|
11066
11629
|
/**
|
|
@@ -11749,7 +12312,7 @@ function agentsStripHtml(view, taskId) {
|
|
|
11749
12312
|
return `<details class="task-chat-agents"><summary>Agent setup</summary><p>${escape(agentsSummaryWords(view))}</p><a href="${taskHref(taskId)}#agents">View agents</a></details>`;
|
|
11750
12313
|
}
|
|
11751
12314
|
/** The demo's one-line promise, on every page. */
|
|
11752
|
-
const DEMO_BANNER =
|
|
12315
|
+
const DEMO_BANNER = `Demo: a scripted lead and sample projects. Nothing calls a model, reaches outside or spends. For your own project, run ${START_COMMAND} in its folder.`;
|
|
11753
12316
|
/** Every character that could open a tag or an attribute, dead at the sink. */
|
|
11754
12317
|
function escape(text) {
|
|
11755
12318
|
return text
|
|
@@ -12743,6 +13306,8 @@ ${THEME_DARK}
|
|
|
12743
13306
|
@keyframes demo-pulse { 50% { opacity: .35; } }
|
|
12744
13307
|
@media (prefers-reduced-motion: reduce) { .demo-steps li.now::before { animation: none; } }
|
|
12745
13308
|
.demo-state { display: flex; align-items: center; gap: .5rem; margin: 0 !important; }
|
|
13309
|
+
.demo-chat .demo-handoff { border-color: var(--so-input-line); }
|
|
13310
|
+
.demo-command { margin: .4rem 0 .5rem; padding: .6rem .8rem; border-radius: calc(var(--radius) - 3px); background: var(--so-raised); font: 500 .8125rem/1.5 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
12746
13311
|
.demo-chat .badge.demo-ready { background: var(--so-info-soft); color: var(--so-info); }
|
|
12747
13312
|
.demo-chat .badge.demo-complete { background: var(--so-success-soft); color: var(--so-success); }
|
|
12748
13313
|
.demo-pass { color: var(--so-success); font-weight: 500; }
|
|
@@ -14523,7 +15088,15 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
|
|
|
14523
15088
|
const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem;margin:0 0 2rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
|
|
14524
15089
|
/** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
|
|
14525
15090
|
export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
|
|
14526
|
-
|
|
15091
|
+
/** The Inbox tabs (console v2): a segmented track of real links; the dot marks a tab with something new, on phones only. */
|
|
15092
|
+
const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width:100%;overflow-x:auto;margin:4px 0 14px;padding:2px;border-radius:12px;background:var(--so-raised);scrollbar-width:none}' +
|
|
15093
|
+
'.inbox-tabs a{position:relative;display:inline-flex;align-items:center;gap:6px;min-height:28px;padding:0 10px;border-radius:8px;color:var(--so-muted);font-size:13px;font-weight:500;text-decoration:none;white-space:nowrap}' +
|
|
15094
|
+
'.inbox-tabs a[aria-current="page"]{background:var(--so-paper);color:var(--so-ink);box-shadow:var(--so-pill-shadow)}' +
|
|
15095
|
+
'.inbox-tab-count{min-width:18px;padding:0 5px;border-radius:9999px;font:500 11px/18px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;text-align:center}' +
|
|
15096
|
+
'.inbox-tab-count--needs{background:var(--so-signal);color:var(--so-on-signal)}' +
|
|
15097
|
+
'.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
|
|
15098
|
+
'@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
|
|
15099
|
+
const WORKSPACE_STYLE = styleAsset(STYLE + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
|
|
14527
15100
|
/**
|
|
14528
15101
|
* The board's liveness: fetch this page's own fragment on a timer and swap
|
|
14529
15102
|
* it in place — no flicker, no scroll reset, no long-lived stream to manage,
|
|
@@ -15073,6 +15646,37 @@ function loginPage(problem, returnTo = "/", sso = null) {
|
|
|
15073
15646
|
`</div></div>`,
|
|
15074
15647
|
].join("\n"), { nav: false });
|
|
15075
15648
|
}
|
|
15649
|
+
/**
|
|
15650
|
+
* The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
|
|
15651
|
+
* command that admits this address. It stands alone (styles inline, no script, no font), because every other asset
|
|
15652
|
+
* would be refused at this address too; and it holds nothing a stranger's page doesn't already know.
|
|
15653
|
+
*/
|
|
15654
|
+
export function wrongHostPage(facts) {
|
|
15655
|
+
const code = (text) => `<code>${escape(text)}</code>`;
|
|
15656
|
+
return [
|
|
15657
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
15658
|
+
`<meta name="color-scheme" content="light dark"><title>Toolroll isn't set up for this address</title>`,
|
|
15659
|
+
`<style>`,
|
|
15660
|
+
`:root{color-scheme:light dark;--ground:#efefef;--paper:#fff;--ink:#171717;--muted:#666;--line:#e6e6e6;--soft:#f2f2f2}`,
|
|
15661
|
+
`@media (prefers-color-scheme:dark){:root{--ground:#0b0b0b;--paper:#161616;--ink:#ededed;--muted:#a1a1a1;--line:#262626;--soft:#1f1f1f}}`,
|
|
15662
|
+
`*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;background:var(--ground);color:var(--ink);`,
|
|
15663
|
+
`font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}`,
|
|
15664
|
+
`main{width:100%;max-width:520px;background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:28px}`,
|
|
15665
|
+
`.mark{font-weight:600;letter-spacing:-.01em;color:var(--muted);margin:0 0 16px}h1{font-size:18px;line-height:1.375;margin:0 0 8px;letter-spacing:-.01em}`,
|
|
15666
|
+
`p{margin:0 0 12px}code{font:12.5px/1.5 ui-monospace,"SF Mono",Menlo,Consolas,monospace;background:var(--soft);border-radius:5px;padding:1px 5px;overflow-wrap:anywhere}`,
|
|
15667
|
+
`pre{margin:0 0 16px;background:var(--soft);border:1px solid var(--line);border-radius:8px;padding:12px 14px;white-space:pre-wrap;overflow-wrap:anywhere}pre code{background:none;padding:0}`,
|
|
15668
|
+
`.meta{color:var(--muted);font-size:13px;margin:0}`,
|
|
15669
|
+
`</style></head><body><main>`,
|
|
15670
|
+
`<p class="mark">Toolroll</p>`,
|
|
15671
|
+
`<h1>Toolroll isn't set up for this address</h1>`,
|
|
15672
|
+
facts.opened === null
|
|
15673
|
+
? `<p>It answers at ${code(facts.served)} on the computer running it.</p>`
|
|
15674
|
+
: `<p>You opened it at ${code(facts.opened)}. It answers at ${code(facts.served)} on the computer running it.</p>`,
|
|
15675
|
+
facts.command === null ? "" : `<p>To use this address, stop Toolroll (Ctrl-C) and start it again with:</p><pre><code>${escape(facts.command)}</code></pre>`,
|
|
15676
|
+
`<p class="meta">On that computer, ${code(`http://${facts.served}/`)} always works. Other addresses need your say-so, so no other website can reach it.</p>`,
|
|
15677
|
+
`</main></body></html>`,
|
|
15678
|
+
].join("\n");
|
|
15679
|
+
}
|
|
15076
15680
|
/** The first-account page (setup review): shown only while no approver exists. */
|
|
15077
15681
|
function signupPage(problem, attemptsLeft) {
|
|
15078
15682
|
return shell("Toolroll", [
|
|
@@ -15096,12 +15700,22 @@ function signupPage(problem, attemptsLeft) {
|
|
|
15096
15700
|
`</div></div>`,
|
|
15097
15701
|
].join("\n"), { nav: false });
|
|
15098
15702
|
}
|
|
15099
|
-
|
|
15100
|
-
|
|
15101
|
-
|
|
15102
|
-
|
|
15103
|
-
|
|
15104
|
-
|
|
15703
|
+
const INBOX_TABS = [
|
|
15704
|
+
{ id: "needs-you", label: "Needs you" }, { id: "ready", label: "Ready" }, { id: "running", label: "Running" }, { id: "all", label: "All" },
|
|
15705
|
+
];
|
|
15706
|
+
export function parseInboxTab(raw) {
|
|
15707
|
+
return INBOX_TABS.some(one => one.id === raw) ? raw : undefined;
|
|
15708
|
+
}
|
|
15709
|
+
/** What each tab holds, as a short fingerprint: a tab is unread when its
|
|
15710
|
+
* fingerprint differs from the one this browser saw there last. */
|
|
15711
|
+
export function inboxFingerprints(data) {
|
|
15712
|
+
const print = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex").slice(0, 8);
|
|
15713
|
+
const needs = [...data.decisions.map(one => `d${one.id}`), ...data.approvals.map(one => `a${one.taskId}@${one.proposedAt}`), ...data.requeueables.map(one => `q${one.taskId}:${one.strikes}`),
|
|
15714
|
+
...data.cancelledBlockers.map(one => `c${one.blockerId}`), ...data.gaps.map(one => `g${one.key}`)];
|
|
15715
|
+
const ready = [...data.needsVerification.map(one => `v${one.taskId}`), ...(data.ready ?? []).map(one => `r${one.taskId}`)];
|
|
15716
|
+
const running = (data.running ?? []).map(one => `u${one.taskId}`);
|
|
15717
|
+
return { "needs-you": print(needs), ready: print(ready), running: print(running), all: print([...needs, ...ready, ...running]) };
|
|
15718
|
+
}
|
|
15105
15719
|
function inboxPage(chrome, data) {
|
|
15106
15720
|
/** The row's project, worn openly in the roll-up — null is UNPLACED,
|
|
15107
15721
|
* said as such, never a silent missing chip (finding 13). */
|
|
@@ -15195,19 +15809,41 @@ function inboxPage(chrome, data) {
|
|
|
15195
15809
|
? `<strong>No builder is connected yet.</strong> Toolroll is open, but no machine is connected to do project work. On the machine where the project lives, open that folder and run <span class="mono">toolroll up</span>. Keep Toolroll running; approved work starts automatically.`
|
|
15196
15810
|
: `<strong>Builder disconnected.</strong> ${data.worker.registered} builder${data.worker.registered === 1 ? " is" : "s are"} configured, last checked in ${data.worker.lastHeard === null ? "never" : whenTime(data.worker.lastHeard)}. Reopen Toolroll on that machine. Queued work starts automatically when a builder reconnects.`) +
|
|
15197
15811
|
`</div>`;
|
|
15812
|
+
// Console v2: Needs you · Ready · Running · All, as real links (Back and
|
|
15813
|
+
// bookmarks work). Each section belongs to one tab; All shows every one.
|
|
15814
|
+
const tab = data.tab ?? "all";
|
|
15815
|
+
const shows = (one) => tab === "all" || tab === one;
|
|
15816
|
+
const listRows = (rows) => rows.map(one => `<p class="row"><a href="${taskHref(one.taskId)}">${escape(one.title)}</a>${chip(one.repo)} <span class="meta">${escape(one.detail)}</span></p>`).join("\n");
|
|
15817
|
+
const ready = (data.ready ?? []).length === 0 ? "" : `<h2>ready to review</h2>` + listRows(data.ready ?? []);
|
|
15818
|
+
const running = (data.running ?? []).length === 0 ? "" : `<h2>running now</h2>` + listRows(data.running ?? []);
|
|
15819
|
+
const counts = {
|
|
15820
|
+
"needs-you": data.decisions.length + data.approvals.length + data.requeueables.length + data.cancelledBlockers.length + data.gaps.length,
|
|
15821
|
+
ready: data.needsVerification.length + (data.ready ?? []).length,
|
|
15822
|
+
running: (data.running ?? []).length,
|
|
15823
|
+
all: 0,
|
|
15824
|
+
};
|
|
15825
|
+
counts.all = counts["needs-you"] + counts.ready + counts.running;
|
|
15826
|
+
const tabs = data.tab === undefined ? "" : `<nav class="inbox-tabs" aria-label="Inbox views">` + INBOX_TABS.map(one => `<a href="/inbox?tab=${one.id}"${one.id === tab ? ` aria-current="page"` : ""} data-inbox-tab="${one.id}">${one.label}` +
|
|
15827
|
+
`<span class="inbox-tab-count${one.id === "needs-you" && counts[one.id] > 0 ? " inbox-tab-count--needs" : ""}">${counts[one.id]}</span>` +
|
|
15828
|
+
`${one.id !== tab && (data.unread ?? []).includes(one.id) ? `<span class="inbox-unread" aria-label="new"></span>` : ""}</a>`).join("") + `</nav>`;
|
|
15829
|
+
const tabEmpty = data.tab !== undefined && tab !== "all" && counts[tab] === 0
|
|
15830
|
+
? `<div class="card"><p class="meta">${tab === "needs-you" ? "Nothing needs you." : tab === "ready" ? "No results are waiting for review." : "Nothing is running."}</p></div>` : "";
|
|
15198
15831
|
return screen("inbox", [
|
|
15199
15832
|
`<h1>Inbox</h1>`,
|
|
15200
15833
|
`<p class="meta">everything that waits on you \u2014 empty means the fleet is working</p>`,
|
|
15834
|
+
tabs,
|
|
15201
15835
|
noWorker,
|
|
15202
15836
|
wizard,
|
|
15203
|
-
empty ? "" : `<p><a class="new-task" style="display:inline-block" href="/next">clear the queue \u2192 one thing at a time</a></p>`,
|
|
15204
|
-
empty && data.wizard === null ? `<div class="card"><p><strong>Nothing needs you.</strong></p><p class="meta">The queue is either working or waiting on its own timers. <a href="/board">Watch the board</a> or <a href="/activity">read the activity report</a>.</p></div>` :
|
|
15205
|
-
decisions,
|
|
15206
|
-
approvals,
|
|
15207
|
-
requeueables,
|
|
15208
|
-
needsVerification,
|
|
15209
|
-
|
|
15210
|
-
|
|
15837
|
+
empty || !shows("needs-you") ? "" : `<p><a class="new-task" style="display:inline-block" href="/next">clear the queue \u2192 one thing at a time</a></p>`,
|
|
15838
|
+
empty && data.wizard === null && shows("needs-you") && counts.all === 0 ? `<div class="card"><p><strong>Nothing needs you.</strong></p><p class="meta">The queue is either working or waiting on its own timers. <a href="/board">Watch the board</a> or <a href="/activity">read the activity report</a>.</p></div>` : tabEmpty,
|
|
15839
|
+
shows("needs-you") ? decisions : "",
|
|
15840
|
+
shows("needs-you") ? approvals : "",
|
|
15841
|
+
shows("needs-you") ? requeueables : "",
|
|
15842
|
+
shows("ready") ? needsVerification : "",
|
|
15843
|
+
shows("ready") ? ready : "",
|
|
15844
|
+
shows("running") ? running : "",
|
|
15845
|
+
shows("needs-you") ? cancelled : "",
|
|
15846
|
+
shows("needs-you") ? gaps : "",
|
|
15211
15847
|
data.rollup
|
|
15212
15848
|
? `<p class="meta">requirement gaps are checked one project at a time \u2014 open a project to see and fill its gaps · <a href="/projects">open a project</a></p>`
|
|
15213
15849
|
: "",
|
|
@@ -15632,6 +16268,11 @@ function approvalFormDigest(scopeDigest, raceDigest, planSha) {
|
|
|
15632
16268
|
.digest("hex");
|
|
15633
16269
|
}
|
|
15634
16270
|
const taskChatHref = (taskId) => `/chat?task=${encodeURIComponent(taskId)}`;
|
|
16271
|
+
export const TASK_COMPOSER_MODES = {
|
|
16272
|
+
build: "The operator chose Build: if they ask for a change to the result, read it with get_result and propose a revision of this task with propose_review (operation revise) for them to confirm. Nothing builds until they confirm the card.",
|
|
16273
|
+
plan: "The operator chose Plan only: answer with a short plan for what they ask. Do not propose a revision, a new task or any other action this turn.",
|
|
16274
|
+
answer: "The operator chose Just answer: answer from what you can read. Do not propose any action this turn.",
|
|
16275
|
+
};
|
|
15635
16276
|
/** A project's own lead thread (v77). */
|
|
15636
16277
|
const projectChatHref = (repo) => `/chat?project=${encodeURIComponent(repo)}`;
|
|
15637
16278
|
/** The one task-level road from a truthful dispatch diagnosis to its nearest
|
|
@@ -15913,6 +16554,7 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
|
|
|
15913
16554
|
? `<details class="task-secondary-approval"><summary>Updated approval terms</summary>${approvalContent}</details>` : approvalContent;
|
|
15914
16555
|
const polling = !inert && ((focus.approval === null || focus.plan === "requested") && focus.state !== "done" && focus.state !== "cancelled" || focus.control.kind === "stopping" || focus.control.kind === "stop");
|
|
15915
16556
|
return (`<section id="task-chat-live" aria-live="polite" data-task="${escape(focus.id)}" data-execution="${escape(focus.executionId)}" data-source="/chat/task-status?task=${encodeURIComponent(focus.id)}" data-poll="${polling ? "1" : "0"}" data-approval="${escape(focus.approval?.digest ?? "")}" data-plan="${escape(focus.plan ?? "")}">` +
|
|
16557
|
+
(focus.guide === true && focus.state !== "cancelled" ? firstTaskJourneyHtml(focus) : "") +
|
|
15916
16558
|
`<div class="task-live-summary">` +
|
|
15917
16559
|
(focus.assignment !== null ? assignmentSummaryHtml(focus.assignment, { workStatus: focus.status, hideAction: focus.approval !== null && focus.dispatch?.action === "approve-scope", ...(receiptLeads ? { resultHref: chatResultHref(focus.id, focus.result.runId) } : {}) }) + (receiptLeads ? completionReceiptCard(focus.result, focus.id, "chat", focus.status, focus.assignment, false) : "") : receiptLeads ? completionReceiptCard(focus.result, focus.id, "chat", focus.status) : taskStatusCard(focus.status, focus.id, focus.dispatch, focus.liveRun?.id ?? null, focus.approval !== null && focus.dispatch?.action === "approve-scope")) +
|
|
15918
16560
|
checkProgressHtml(focus.checkProgress) +
|
|
@@ -15936,6 +16578,16 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
|
|
|
15936
16578
|
(focus.publication === null ? "" : `<p class="chat-publication meta">Published as ${safePrUrl(focus.publication.prUrl) === null ? `<span class="mono">PR #${focus.publication.prNumber ?? "?"}</span>` : `<a href="${escape(safePrUrl(focus.publication.prUrl))}">PR #${focus.publication.prNumber ?? "?"}</a>`} · ${escape(focus.publication.state)}${focus.publication.lastCheckState === null ? "" : ` · CI ${escape(focus.publication.lastCheckState)}`}</p>`) +
|
|
15937
16579
|
`</section>`);
|
|
15938
16580
|
}
|
|
16581
|
+
/** The first task's way to Ready, filled in as it moves (onboarding): the same stage every surface reads. */
|
|
16582
|
+
function firstTaskJourneyHtml(focus) {
|
|
16583
|
+
const planning = focus.plan === "requested" && focus.approval === null;
|
|
16584
|
+
const read = focus.assignment !== null ? assignmentStageOf(focus.assignment, { token: focus.status.token }, planning) : stageOfCode(focus.status.token, { planning });
|
|
16585
|
+
const steps = firstTaskJourney(read, focus.scope === "approved", focus.state === "done");
|
|
16586
|
+
const current = steps.find(one => one.state === "current" || one.state === "stuck");
|
|
16587
|
+
return `<ol class="first-task-journey" aria-label="Where this task is${current === undefined ? ": Ready" : `: ${escape(current.label)}`}" data-first-task-journey>` +
|
|
16588
|
+
steps.map(one => `<li data-step="${one.key}" data-state="${one.state}"${one.state === "current" || one.state === "stuck" ? ` aria-current="step"` : ""}><span class="first-task-journey-mark" aria-hidden="true"></span><span>${escape(one.label)}</span></li>`).join("") +
|
|
16589
|
+
`</ol>`;
|
|
16590
|
+
}
|
|
15939
16591
|
function taskChatHeading(focus) {
|
|
15940
16592
|
return (`<div class="chat-head task-chat-head"><div>` +
|
|
15941
16593
|
`<p class="meta chat-task-back"><a href="${taskHref(focus.id)}">← task overview</a></p>` +
|
|
@@ -16195,55 +16847,83 @@ function chatLimitsHtml(facts) {
|
|
|
16195
16847
|
rows.push(`<span>this week ${chatMoney(facts.weekly.spent)} of ${chatMoney(facts.weekly.ceiling)}</span>`);
|
|
16196
16848
|
return `<details class="chat-limits"><summary>Model & limits<span class="meta">${escape(facts.subscription ? "membership" : facts.provider)}</span></summary><div class="chat-budget">${rows.join("")}</div></details>`;
|
|
16197
16849
|
}
|
|
16850
|
+
/** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
|
|
16851
|
+
function leadSettingsHtml(data) {
|
|
16852
|
+
const { config, facts } = data;
|
|
16853
|
+
const hidden = `<input type="hidden" name="csrf" value="${escape(facts.csrf)}"><input type="hidden" name="return" value="/settings/lead">`;
|
|
16854
|
+
const summary = config === null
|
|
16855
|
+
? data.signedIn !== null
|
|
16856
|
+
? `<p>The lead is off.</p><form method="post" action="/settings/lead/on">${hidden}<button type="submit">Use your ${escape(data.signedIn)} sign-in</button></form>`
|
|
16857
|
+
: `<p>The lead is off. Sign in an agent on this computer to turn it on:</p><p><code>${escape(data.command)}</code></p>`
|
|
16858
|
+
: data.words !== null
|
|
16859
|
+
? `<p>The lead uses ${escape(data.words)}.</p><p class="meta">${config.model === "default" ? "Its default model" : `Model ${escape(config.model)}`} · up to ${config.dailyTurns} turns a day · no dollar spend. Every action it proposes waits for you to confirm it.</p>`
|
|
16860
|
+
: `<p>The lead uses the ${escape(config.provider === "anthropic-api" ? "Anthropic" : "OpenRouter")} API with ${escape(config.model)}.</p><p class="meta">Up to ${chatMoney(config.weeklyCeilingMicrousd)} a week · up to ${config.dailyTurns} turns a day.</p>`;
|
|
16861
|
+
const forget = facts.keyFacts.filter(one => one.state === "stored").map(one => `<form method="post" action="/chat/config" class="inline">${hidden}<input type="hidden" name="forget-key" value="${escape(one.provider)}">` +
|
|
16862
|
+
`<input type="password" name="token" placeholder="your password" autocomplete="current-password" aria-label="Your password"><button type="submit" class="secondary">Forget the stored ${escape(one.provider)} key</button></form>`).join("");
|
|
16863
|
+
return [
|
|
16864
|
+
`<p><a href="/settings">Settings</a></p><h1>Lead</h1>`,
|
|
16865
|
+
data.said === null ? "" : `<p class="problem" role="status">${escape(data.said)}</p>`,
|
|
16866
|
+
`<section class="card lead-settings" data-lead-settings>${summary}</section>`,
|
|
16867
|
+
`<details class="lead-advanced" data-lead-advanced><summary>Advanced</summary>`,
|
|
16868
|
+
leadConfigForm(config, facts),
|
|
16869
|
+
config === null ? "" : `<form method="post" action="/chat/config" class="inline">${hidden}<input type="hidden" name="off" value="1">` +
|
|
16870
|
+
`<input type="password" name="token" placeholder="your password" autocomplete="current-password" aria-label="Your password"><button type="submit" class="secondary">Turn the lead off</button></form>`,
|
|
16871
|
+
forget,
|
|
16872
|
+
`</details>`,
|
|
16873
|
+
].join("\n");
|
|
16874
|
+
}
|
|
16875
|
+
/** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and a direct API key. */
|
|
16876
|
+
function leadConfigForm(current, data) {
|
|
16877
|
+
const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
|
|
16878
|
+
const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
|
|
16879
|
+
const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
|
|
16880
|
+
const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
|
|
16881
|
+
const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
|
|
16882
|
+
return [
|
|
16883
|
+
`<form method="post" action="/chat/config" class="card">`,
|
|
16884
|
+
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
|
|
16885
|
+
`<input type="hidden" name="return" value="${escape(data.returnTo)}">`,
|
|
16886
|
+
`<label>provider<select name="provider">`,
|
|
16887
|
+
`<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
|
|
16888
|
+
`<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
|
|
16889
|
+
`<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
|
|
16890
|
+
`<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
|
|
16891
|
+
`</select></label>`,
|
|
16892
|
+
`<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
|
|
16893
|
+
`<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
|
|
16894
|
+
`${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
|
|
16895
|
+
data.openrouterModels === null
|
|
16896
|
+
? `<p class="meta">with OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog — each model priced by the party that bills it</p>`
|
|
16897
|
+
: `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
|
|
16898
|
+
currentSubscription
|
|
16899
|
+
? `<p class="meta"><strong>no dollar maximum.</strong> Membership chat uses the plan attached to the logged-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.</p>`
|
|
16900
|
+
: `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
|
|
16901
|
+
`<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
|
|
16902
|
+
`<label>daily turns <span class="meta">(default 50)</span>` +
|
|
16903
|
+
`<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
|
|
16904
|
+
currentSubscription
|
|
16905
|
+
? `<p class="meta">Authenticate on this machine first with ${current?.provider === "codex-subscription" ? `<span class="mono">codex login</span>` : `the <span class="mono">claude</span> CLI`}. Toolroll reuses that cached login and never stores it.</p>`
|
|
16906
|
+
: `<label>API key <span class="meta">(${data.keyFacts
|
|
16907
|
+
.map(one => one.state === "none"
|
|
16908
|
+
? `${escape(one.provider)}: none yet`
|
|
16909
|
+
: one.state === "environment"
|
|
16910
|
+
? `${escape(one.provider)}: from the environment`
|
|
16911
|
+
: `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
|
|
16912
|
+
.join(" · ")})</span>` +
|
|
16913
|
+
`<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
|
|
16914
|
+
`<label>your password <span class="meta">(typed again to change the provider)</span>` +
|
|
16915
|
+
`<input type="password" name="token" autocomplete="current-password"></label>`,
|
|
16916
|
+
`<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
|
|
16917
|
+
`</form>`,
|
|
16918
|
+
currentSubscription
|
|
16919
|
+
? `<p class="meta">The membership provider runs without repository tools in a temporary directory; Toolroll remains the only layer that can turn a proposed action into a confirmation card.</p>`
|
|
16920
|
+
: `<p class="meta">a pasted key is written once to a mode-0600 file beside the database — never INTO the database, never shown again beyond its last characters; an environment variable (` +
|
|
16921
|
+
`<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
|
|
16922
|
+
].join("\n");
|
|
16923
|
+
}
|
|
16198
16924
|
function chatPage(chrome, data) {
|
|
16199
|
-
const
|
|
16200
|
-
|
|
16201
|
-
const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
|
|
16202
|
-
const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
|
|
16203
|
-
const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
|
|
16204
|
-
const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
|
|
16205
|
-
return [
|
|
16206
|
-
`<form method="post" action="/chat/config" class="card">`,
|
|
16207
|
-
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
|
|
16208
|
-
`<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">`,
|
|
16209
|
-
`<label>provider<select name="provider">`,
|
|
16210
|
-
`<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
|
|
16211
|
-
`<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
|
|
16212
|
-
`<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
|
|
16213
|
-
`<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
|
|
16214
|
-
`</select></label>`,
|
|
16215
|
-
`<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
|
|
16216
|
-
`<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
|
|
16217
|
-
`${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
|
|
16218
|
-
data.openrouterModels === null
|
|
16219
|
-
? `<p class="meta">with OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog — each model priced by the party that bills it</p>`
|
|
16220
|
-
: `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
|
|
16221
|
-
currentSubscription
|
|
16222
|
-
? `<p class="meta"><strong>no dollar maximum.</strong> Membership chat uses the plan attached to the logged-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.</p>`
|
|
16223
|
-
: `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
|
|
16224
|
-
`<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
|
|
16225
|
-
`<label>daily turns <span class="meta">(default 50)</span>` +
|
|
16226
|
-
`<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
|
|
16227
|
-
currentSubscription
|
|
16228
|
-
? `<p class="meta">Authenticate on this machine first with ${current?.provider === "codex-subscription" ? `<span class="mono">codex login</span>` : `the <span class="mono">claude</span> CLI`}. Toolroll reuses that cached login and never stores it.</p>`
|
|
16229
|
-
: `<label>API key <span class="meta">(${data.keyFacts
|
|
16230
|
-
.map(one => one.state === "none"
|
|
16231
|
-
? `${escape(one.provider)}: none yet`
|
|
16232
|
-
: one.state === "environment"
|
|
16233
|
-
? `${escape(one.provider)}: from the environment`
|
|
16234
|
-
: `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
|
|
16235
|
-
.join(" · ")})</span>` +
|
|
16236
|
-
`<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
|
|
16237
|
-
`<label>your password <span class="meta">(typed again to change the provider)</span>` +
|
|
16238
|
-
`<input type="password" name="token" autocomplete="current-password"></label>`,
|
|
16239
|
-
`<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
|
|
16240
|
-
`</form>`,
|
|
16241
|
-
currentSubscription
|
|
16242
|
-
? `<p class="meta">The membership provider runs without repository tools in a temporary directory; Toolroll remains the only layer that can turn a proposed action into a confirmation card.</p>`
|
|
16243
|
-
: `<p class="meta">a pasted key is written once to a mode-0600 file beside the database — never INTO the database, never shown again beyond its last characters; an environment variable (` +
|
|
16244
|
-
`<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
|
|
16245
|
-
].join("\n");
|
|
16246
|
-
};
|
|
16925
|
+
const formFacts = { keyFacts: data.keyFacts, openrouterModels: data.openrouterModels, ...(data.liveModels === undefined ? {} : { liveModels: data.liveModels }), csrf: data.csrf,
|
|
16926
|
+
returnTo: data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id) };
|
|
16247
16927
|
const parts = [
|
|
16248
16928
|
data.focusTask === null
|
|
16249
16929
|
? chatHeading("", data.projects.length, data.enabled.ok)
|
|
@@ -16256,14 +16936,17 @@ function chatPage(chrome, data) {
|
|
|
16256
16936
|
const code = data.enabled.code;
|
|
16257
16937
|
// The sandbox shows no conversation at all: chat evidence is a real
|
|
16258
16938
|
// subscription-backed plane, never a seeded transcript (v48 authority repair).
|
|
16259
|
-
|
|
16260
|
-
|
|
16261
|
-
|
|
16262
|
-
|
|
16263
|
-
|
|
16264
|
-
|
|
16265
|
-
|
|
16266
|
-
|
|
16939
|
+
// With no lead yet, the first run says what happens next (it turns on with the signed-in agent, or shows the one
|
|
16940
|
+
// command to run); otherwise one line, and its settings live in Settings → Lead (onboarding).
|
|
16941
|
+
const settingsLink = data.canManage ? ` <a href="/settings/lead">Settings → Lead</a>` : "";
|
|
16942
|
+
if (!(code === "unconfigured" && data.firstRunShown === true))
|
|
16943
|
+
parts.push(code === "demo"
|
|
16944
|
+
? `<div class="card" id="latest"><p><strong>Chat isn’t available in demo mode</strong></p><p class="meta">Demo data never contacts an external model. Start Toolroll with a real project to use chat: <code>${escape(START_COMMAND)}</code> in your repository.</p></div>`
|
|
16945
|
+
: code === "unconfigured"
|
|
16946
|
+
? `<div class="card" id="latest" data-lead-off><p><strong>The lead is off.</strong></p><p class="meta">${data.canManage ? `Turn it on in${settingsLink}.` : "An approver can turn it on."}</p></div>`
|
|
16947
|
+
: code === "unpriced" || code === "no-key"
|
|
16948
|
+
? `<div class="card" id="latest"><p><strong>The lead can’t run yet.</strong></p><p class="meta">${escape(data.enabled.why)}${data.canManage ? ` ·${settingsLink}` : ""}</p></div>`
|
|
16949
|
+
: `<div class="card" id="latest"><p><strong>chat is off.</strong></p><p class="meta">${escape(data.enabled.why)}</p></div>`);
|
|
16267
16950
|
return screen("chat", chatWorkspace(parts.join("\n"), data.projects, data.csrf, true, data.focusTask, data.resultPanel ?? null), { chrome, functional: { script: CHAT_UI_SCRIPT + (data.focusTask === null ? "" : RESULT_REVIEW_SCRIPT), fetches: data.focusTask !== null } });
|
|
16268
16951
|
}
|
|
16269
16952
|
const config = data.enabled.config;
|
|
@@ -16319,15 +17002,7 @@ function chatPage(chrome, data) {
|
|
|
16319
17002
|
parts.push(`<h2>ask</h2>`, `<form method="post" action="/chat" class="card">`, `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`, `<label>message<textarea name="message" rows="3" maxlength="2000"></textarea></label>`, `<label>your password <span class="meta">(every message — chat spends)</span><input type="password" name="token" autocomplete="current-password"></label>`, `<button type="submit">ask</button>`, `</form>`);
|
|
16320
17003
|
}
|
|
16321
17004
|
if (data.canManage) {
|
|
16322
|
-
parts.push(`<
|
|
16323
|
-
.filter(one => one.state === "stored")
|
|
16324
|
-
.map(one => `<form method="post" action="/chat/config" class="inline">` +
|
|
16325
|
-
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">` +
|
|
16326
|
-
`<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">` +
|
|
16327
|
-
`<input type="hidden" name="forget-key" value="${escape(one.provider)}">` +
|
|
16328
|
-
`<input type="password" name="token" placeholder="your password" autocomplete="current-password">` +
|
|
16329
|
-
`<button type="submit">forget the stored ${escape(one.provider)} key</button></form>`)
|
|
16330
|
-
.join("\n"), `</details>`);
|
|
17005
|
+
parts.push(`<p class="meta" id="chat-settings"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`);
|
|
16331
17006
|
}
|
|
16332
17007
|
if (data.recent.length > 0) {
|
|
16333
17008
|
parts.push(`<details class="chat-turn-history"><summary>Conversation activity <span class="meta">${data.recent.length} recent turns</span></summary>`);
|
|
@@ -16444,7 +17119,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
|
|
|
16444
17119
|
: "inspect first when the work needs repository context";
|
|
16445
17120
|
what =
|
|
16446
17121
|
`<h3>${escape(text("title"))}</h3><p class="proposal-summary">${escape(text("goal"))}</p>` +
|
|
16447
|
-
facts(["project", `<span class="mono">${escape(repoId)}</span>`], ["deliverable", payload["report"] === true ? "report only" : "branch"], ["planning", planningWords], ["out of scope", escape(text("not"))], ["may touch", Array.isArray(payload["touches"]) ? payload["touches"].map(one => `<span class="mono">${escape(one)}</span>`).join("<br>") : ""]);
|
|
17122
|
+
facts(["project", `<span class="mono">${escape(repoId)}</span>`], ["deliverable", payload["report"] === true ? "report only" : "branch"], ["planning", planningWords], ["checks", isCheckLevel(payload["checks"]) ? { quick: "Quick checks", full: "Full checks", off: "Off — built, not checked" }[payload["checks"]] : ""], ["out of scope", escape(text("not"))], ["may touch", Array.isArray(payload["touches"]) ? payload["touches"].map(one => `<span class="mono">${escape(one)}</span>`).join("<br>") : ""]);
|
|
16448
17123
|
}
|
|
16449
17124
|
else if (view.kind === "action") {
|
|
16450
17125
|
const action = sharedActionPayload(payload);
|
|
@@ -16807,7 +17482,7 @@ function matePage(chrome, data) {
|
|
|
16807
17482
|
: `<p class="meta">recent turns: ${data.recent
|
|
16808
17483
|
.map(turn => `<span class="mono">#${turn.id}</span> ${escape(turn.state)}${turn.failureReason === null ? "" : ` · ${escape(turn.failureReason)}`} · ${subscription ? "membership" : chatMoney(turn.settledMicrousd ?? turn.reservedMicrousd)}`)
|
|
16809
17484
|
.join(" · ")}</p>`,
|
|
16810
|
-
`<p class="meta"><a href="/
|
|
17485
|
+
`<p class="meta"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`,
|
|
16811
17486
|
`</details>`,
|
|
16812
17487
|
].join("\n");
|
|
16813
17488
|
const conversation = [
|
|
@@ -17303,7 +17978,7 @@ function workPage(chrome, data) {
|
|
|
17303
17978
|
};
|
|
17304
17979
|
return screen('work', `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${list}${pages}`, { chrome, workspace: { view } });
|
|
17305
17980
|
}
|
|
17306
|
-
function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default") {
|
|
17981
|
+
function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default", replaced = new Map()) {
|
|
17307
17982
|
const filters = TASK_STATES.map(one => (one === state ? `<strong>${one}</strong>` : `<a href="/tasks?state=${one}">${one}</a>`)).join(" · ");
|
|
17308
17983
|
const rows = tasks.length === 0
|
|
17309
17984
|
? `<p class="meta">${state === null
|
|
@@ -17311,7 +17986,7 @@ function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = n
|
|
|
17311
17986
|
: `Nothing is ${escape(state)}.`}</p>`
|
|
17312
17987
|
: tasks
|
|
17313
17988
|
.map(task => `<a class="row" href="${taskHref(task.id)}"><span class="mono">${escape(task.id)}</span> ` +
|
|
17314
|
-
`${escape(task.title)} <span class="right badge badge-${escape(task.state)}">${escape(task.state)}</span></a>`)
|
|
17989
|
+
`${escape(task.title)} <span class="right badge badge-${escape(task.state)}">${escape(task.state === "cancelled" && replaced.has(task.id) ? `replaced by ${replaced.get(task.id)}` : task.state)}</span></a>`)
|
|
17315
17990
|
.join("\n");
|
|
17316
17991
|
return screen("tasks", [
|
|
17317
17992
|
"<h1>Tasks</h1>",
|
|
@@ -17982,7 +18657,7 @@ function githubReposPage(chrome, data) {
|
|
|
17982
18657
|
`<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
|
|
17983
18658
|
].join("\n"), { chrome });
|
|
17984
18659
|
}
|
|
17985
|
-
function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn) {
|
|
18660
|
+
function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn, checkLevelOf) {
|
|
17986
18661
|
// The onboarding card (repo onboarding, findings 1-39): preview first,
|
|
17987
18662
|
// then a password-confirmed clone into a configured root. Disabled
|
|
17988
18663
|
// states explain themselves in words (finding 28/39).
|
|
@@ -18073,6 +18748,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
|
|
|
18073
18748
|
name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
|
|
18074
18749
|
knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
|
|
18075
18750
|
...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
|
|
18751
|
+
...(checkLevelOf === undefined ? {} : { checks: { href: `/settings/checks?repo=${encodeURIComponent(path)}`, level: CHECK_LEVEL_WORDS[checkLevelOf(path)] } }),
|
|
18076
18752
|
peek: peek === null ? null : [
|
|
18077
18753
|
...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
|
|
18078
18754
|
...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
|
|
@@ -18223,6 +18899,10 @@ function loginReturn(raw) {
|
|
|
18223
18899
|
const query = parsed.searchParams.toString();
|
|
18224
18900
|
return `${parsed.pathname}${query === "" ? "" : `?${query}`}`;
|
|
18225
18901
|
}
|
|
18902
|
+
/** A request path fit for a log: the one-time sign-in link's code is left out. */
|
|
18903
|
+
export function redactedPath(path) {
|
|
18904
|
+
return path.startsWith(SIGN_IN_LINK_PATH) ? `${SIGN_IN_LINK_PATH}…` : path;
|
|
18905
|
+
}
|
|
18226
18906
|
/** The sign-in page that comes back to `path` afterwards, or plain /login when the path is not one to come back to. */
|
|
18227
18907
|
function loginHref(path) {
|
|
18228
18908
|
const back = loginReturn(path);
|
|
@@ -19506,7 +20186,7 @@ function taskBodyParts(data) {
|
|
|
19506
20186
|
facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/board?view=order" }] });
|
|
19507
20187
|
}
|
|
19508
20188
|
facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
|
|
19509
|
-
: approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by} ·
|
|
20189
|
+
: approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
|
|
19510
20190
|
: { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
|
|
19511
20191
|
facts.push({ label: "Publishes as", parts: [publishesAs] });
|
|
19512
20192
|
// v102: who asked for it, and what the project's approval rules need.
|
|
@@ -19824,6 +20504,135 @@ function taskBodyParts(data) {
|
|
|
19824
20504
|
cancelAct,
|
|
19825
20505
|
`</div><aside class="task-rail">${rail}</aside></div>`,
|
|
19826
20506
|
].join("\n");
|
|
20507
|
+
// Console v2: one thread in time order (the plan, the agent's notes and
|
|
20508
|
+
// results, its questions, the person's replies) and the metadata grouped
|
|
20509
|
+
// for a Details panel. Every form below keeps its own route and fields.
|
|
20510
|
+
const familyRuns = data.family?.runs ?? data.runs.map(run => ({ ...run, taskId: task.id }));
|
|
20511
|
+
const versionLabelOf = (taskId) => {
|
|
20512
|
+
const index = data.family?.versions.findIndex(one => one.id === taskId) ?? -1;
|
|
20513
|
+
return index <= 0 ? "" : ` · revision ${index}`;
|
|
20514
|
+
};
|
|
20515
|
+
const thread = [];
|
|
20516
|
+
const filedBy = data.filer?.name ?? data.coordinator?.label ?? null;
|
|
20517
|
+
const rootFiled = data.family?.root.createdAt ?? task.createdAt;
|
|
20518
|
+
thread.push({ key: "filed", at: rootFiled, kind: "filed", who: "person", author: filedBy ?? "", title: "Filed the task",
|
|
20519
|
+
text: (() => { const goal = data.family?.root.goal ?? scope?.goal ?? null; return goal === null ? null : oneLineOf(goal, 280); })(), link: null, html: "", more: null });
|
|
20520
|
+
const newestBuilt = familyRuns.filter(run => run.outcome === "built" && run.role !== "planner" && run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
|
|
20521
|
+
for (const run of familyRuns) {
|
|
20522
|
+
const live = run.id === liveHistoryRunId || (run.outcome === null && run.id === liveRunId);
|
|
20523
|
+
const noun = runNoun(run);
|
|
20524
|
+
const href = { label: `${noun[0].toUpperCase()}${noun.slice(1)} #${run.id}`, href: `/r/${run.id}` };
|
|
20525
|
+
const revision = versionLabelOf(run.taskId);
|
|
20526
|
+
if (run.role === "planner") {
|
|
20527
|
+
thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "plan", who: "agent", author: run.runner,
|
|
20528
|
+
title: live ? "Planning" : run.reason === "plan-drafted" ? "Drafted a plan" : `Planning ended · ${run.outcome === null ? "never finished" : run.reason === null ? run.outcome : reasonWords(run.reason)}`,
|
|
20529
|
+
text: null, link: href, html: "", more: null });
|
|
20530
|
+
continue;
|
|
20531
|
+
}
|
|
20532
|
+
if (live) {
|
|
20533
|
+
thread.push({ key: `run-${run.id}`, at: run.startedAt, kind: "progress", who: "agent", author: run.runner,
|
|
20534
|
+
title: `${run.role === "reviewer" ? "Reviewing" : run.role === "scout" ? "Investigating" : "Building"}${revision}`,
|
|
20535
|
+
text: `${homePhaseWords(run)}.`, link: href, html: "", more: null });
|
|
20536
|
+
continue;
|
|
20537
|
+
}
|
|
20538
|
+
const title = run.role === "reviewer" ? "Reviewed the result"
|
|
20539
|
+
: run.outcome === "built" ? (run.role === "scout" ? "Report ready" : "Result ready")
|
|
20540
|
+
: run.outcome === "no-change" ? "Finished with no change"
|
|
20541
|
+
: run.outcome === "parked" ? "Stopped to ask"
|
|
20542
|
+
: run.outcome === null ? "Attempt never finished"
|
|
20543
|
+
: `${noun[0].toUpperCase()}${noun.slice(1)} ${run.outcome}`;
|
|
20544
|
+
thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "result", who: "agent", author: run.runner,
|
|
20545
|
+
title: `${title}${revision}`, text: run.handoff === null ? (run.reason === null || run.outcome === "built" ? null : reasonWords(run.reason)) : oneLineOf(run.handoff, 600),
|
|
20546
|
+
link: href, html: "",
|
|
20547
|
+
more: previousResult !== null && run.id === newestBuilt?.id && run.taskId === task.id ? { summary: "Result details", html: previousResult.html } : null });
|
|
20548
|
+
}
|
|
20549
|
+
// What the agent is working through now, and how its plan changed.
|
|
20550
|
+
const liveAt = liveRun?.startedAt ?? task.updatedAt;
|
|
20551
|
+
if (planCard !== "")
|
|
20552
|
+
thread.push({ key: "plan", at: familyRuns.filter(run => run.role === "planner").map(run => run.finishedAt ?? run.startedAt).sort().at(-1) ?? task.updatedAt,
|
|
20553
|
+
kind: "plan", who: "agent", author: "", title: "The plan", text: null, link: null, html: planCard, more: null });
|
|
20554
|
+
if (progressCard !== "")
|
|
20555
|
+
thread.push({ key: "progress", at: liveAt, kind: "progress", who: "agent", author: "", title: "Progress", text: null, link: null, html: progressCard, more: null });
|
|
20556
|
+
if (revisionLedgerCard !== "")
|
|
20557
|
+
thread.push({ key: "plan-revisions", at: liveAt, kind: "progress", who: "agent", author: "", title: "Plan changes", text: null, link: null, html: revisionLedgerCard, more: null });
|
|
20558
|
+
// The change a person asked for: the revision's own brief.
|
|
20559
|
+
if (data.revision != null && !("problem" in data.revision)) {
|
|
20560
|
+
const feedback = data.revision;
|
|
20561
|
+
thread.push({ key: "revision", at: task.createdAt, kind: "reply", who: "person", author: feedback.comments[0]?.author ?? "You",
|
|
20562
|
+
title: feedback.kind === "ci-repair" ? "Asked for a CI repair" : feedback.kind === "criterion-repair" ? "Asked for a repair" : "Asked for changes",
|
|
20563
|
+
text: feedback.comments.map(one => `${one.path === null ? "" : `${one.path}${one.line === null ? "" : `:${one.line}`} — `}${one.note}`).join("\n"),
|
|
20564
|
+
link: { label: `From build #${feedback.sourceRun}`, href: `/r/${feedback.sourceRun}` }, html: "", more: null });
|
|
20565
|
+
}
|
|
20566
|
+
for (const decision of data.decisions) {
|
|
20567
|
+
if (decision.state === "open" || decision.state === "expired")
|
|
20568
|
+
continue;
|
|
20569
|
+
thread.push({ key: `question-${decision.id}`, at: decision.createdAt, kind: "question", who: "agent", author: "", title: "Asked",
|
|
20570
|
+
text: decision.question, link: { label: "Question", href: `/d/${decision.id}` }, html: "", more: null });
|
|
20571
|
+
if (decision.answeredAt !== null) {
|
|
20572
|
+
const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice;
|
|
20573
|
+
thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt, kind: "reply", who: "person", author: decision.answeredBy ?? "You", title: "Answered",
|
|
20574
|
+
text: [chosen, decision.note].filter((one) => one !== null && one !== "").join(" — ") || null, link: null, html: "", more: null });
|
|
20575
|
+
}
|
|
20576
|
+
}
|
|
20577
|
+
// Open questions keep their answer card (and its #task-questions anchor).
|
|
20578
|
+
if (decisionRail !== "")
|
|
20579
|
+
thread.push({ key: "questions", at: openDecisions.map(one => one.createdAt).sort()[0] ?? task.updatedAt, kind: "question", who: "agent", author: "",
|
|
20580
|
+
title: openDecisions.length === 1 ? "Asked you a question" : `Asked you ${openDecisions.length} questions`, text: null, link: null, html: decisionRail, more: null });
|
|
20581
|
+
for (const note of data.steering ?? []) {
|
|
20582
|
+
thread.push({ key: `steer-${note.id}`, at: note.createdAt, kind: "reply", who: "person", author: note.author, title: "Note to the agent", text: note.note, link: null, html: "", more: null });
|
|
20583
|
+
}
|
|
20584
|
+
thread.sort((a, b) => a.at === b.at ? 0 : a.at < b.at ? -1 : 1);
|
|
20585
|
+
// The Details panel: the same facts, grouped and said once.
|
|
20586
|
+
const lastRunForAgent = liveRun ?? familyRuns.filter(run => run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
|
|
20587
|
+
const work = [];
|
|
20588
|
+
work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
|
|
20589
|
+
if (data.repo !== null)
|
|
20590
|
+
work.push({ label: "Project", parts: [projectName(data.repo)] });
|
|
20591
|
+
work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
|
|
20592
|
+
: data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
|
|
20593
|
+
if (scope !== null)
|
|
20594
|
+
work.push({ label: "Checks level", parts: [qualityModeTitle(scope.qualityMode ?? "default")] });
|
|
20595
|
+
for (const label of ["Worker", "Last attempt", "Queue", "Budget", "Policy", "Strikes"]) {
|
|
20596
|
+
const fact = facts.find(one => one.label === label);
|
|
20597
|
+
if (fact !== undefined)
|
|
20598
|
+
work.push(fact);
|
|
20599
|
+
}
|
|
20600
|
+
const links = [];
|
|
20601
|
+
if (data.family != null && data.family.root.id !== task.id)
|
|
20602
|
+
links.push({ label: "Parent", parts: [{ label: data.family.root.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(data.family.root.id)}` }] });
|
|
20603
|
+
const blockers = data.waitsFor ?? [];
|
|
20604
|
+
if (blockers.length > 0)
|
|
20605
|
+
links.push({ label: "Blocked by", parts: blockers.flatMap((one, index) => [...(index === 0 ? [] : [", "]), one.admitted ? { label: one.title ?? one.id, href: taskHref(one.id) } : one.id]) });
|
|
20606
|
+
const laterVersions = (data.family?.versions ?? []).filter((one, index, all) => index > all.findIndex(version => version.id === task.id) && all.findIndex(version => version.id === task.id) >= 0);
|
|
20607
|
+
if (laterVersions.length > 0)
|
|
20608
|
+
links.push({ label: "Follow-ups", parts: laterVersions.flatMap((one, index) => [...(index === 0 ? [] : [", "]), { label: one.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(one.id)}` }]) });
|
|
20609
|
+
const published = facts.find(one => one.label === "Published");
|
|
20610
|
+
if (published !== undefined)
|
|
20611
|
+
links.push({ ...published, label: "Pull request" });
|
|
20612
|
+
else if (data.pullRequest?.view != null) {
|
|
20613
|
+
const pr = data.pullRequest.view;
|
|
20614
|
+
const prHref = safePrUrl(pr.prUrl);
|
|
20615
|
+
links.push({ label: "Pull request", parts: [prHref === null ? `#${pr.prNumber ?? "?"}` : { label: `#${pr.prNumber ?? "?"}`, href: prHref }, ` · ${pr.label}`] });
|
|
20616
|
+
}
|
|
20617
|
+
const review = [];
|
|
20618
|
+
const approvedScope = facts.find(one => one.label === "Approved scope" || one.label === "Scope");
|
|
20619
|
+
if (approvedScope !== undefined)
|
|
20620
|
+
review.push({ ...approvedScope, label: approvedScope.label === "Approved scope" ? "Approvals" : "Scope" });
|
|
20621
|
+
review.push({ label: "Who reviews", parts: [facts.find(one => one.label === "Approval rules")?.parts[0] ?? "Any approver in this project"] });
|
|
20622
|
+
review.push({ label: "Publishes as", parts: [publishesAs] });
|
|
20623
|
+
const about = [];
|
|
20624
|
+
const filedFact = facts.find(one => one.label === "Filed by");
|
|
20625
|
+
about.push(filedFact ?? { label: "Filed by", parts: [filedBy ?? (data.filedVia == null ? "Not recorded" : `via ${data.filedVia}`)] });
|
|
20626
|
+
about.push({ label: "Filed", parts: [{ at: rootFiled }] });
|
|
20627
|
+
about.push({ label: "Updated", parts: [{ at: task.updatedAt }] });
|
|
20628
|
+
for (const label of ["Audit", "This attempt", "Task total"]) {
|
|
20629
|
+
const fact = facts.find(one => one.label === label);
|
|
20630
|
+
if (fact !== undefined)
|
|
20631
|
+
about.push({ ...fact, label: label === "Audit" ? "Audit" : label === "This attempt" ? "Usage, this attempt" : "Usage, all attempts" });
|
|
20632
|
+
}
|
|
20633
|
+
const details = [
|
|
20634
|
+
{ title: "Work", facts: work }, { title: "Links", facts: links }, { title: "Review", facts: review }, { title: "About", facts: about },
|
|
20635
|
+
];
|
|
19827
20636
|
// The rebuilt page (shadcn/ui): the same parts in a calmer order — what
|
|
19828
20637
|
// needs a person, then facts, then folds; the mechanics under Manage.
|
|
19829
20638
|
const MANAGE = new Set(["steering", "waits-for", "holds"]);
|
|
@@ -19842,25 +20651,35 @@ function taskBodyParts(data) {
|
|
|
19842
20651
|
status: data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions) : null,
|
|
19843
20652
|
statusHtml,
|
|
19844
20653
|
approval: approvalHtml,
|
|
20654
|
+
// The plan, progress and plan changes are thread entries now; the rest still needs a person here.
|
|
19845
20655
|
lead: [
|
|
19846
20656
|
{ key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
|
|
19847
20657
|
{ key: "pull-request", html: pullRequestCard },
|
|
19848
|
-
{ key: "needs-scope", html: needsScopeCard },
|
|
19849
|
-
{ key: "
|
|
20658
|
+
{ key: "needs-scope", html: needsScopeCard },
|
|
20659
|
+
{ key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
|
|
19850
20660
|
].filter(one => one.html !== ""),
|
|
19851
|
-
questions
|
|
20661
|
+
// Open questions are answered in the thread.
|
|
20662
|
+
questions: "",
|
|
19852
20663
|
facts,
|
|
19853
20664
|
sections: [
|
|
19854
|
-
...(previousResult === null ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
|
|
19855
|
-
//
|
|
19856
|
-
...sectionParts.filter(one => !MANAGE.has(one.id)).map(one => one.id === "scope" &&
|
|
20665
|
+
...(previousResult === null || thread.some(one => one.more !== null) ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
|
|
20666
|
+
// Questions are in the thread; the scope and the ledgers fold here until asked.
|
|
20667
|
+
...sectionParts.filter(one => !MANAGE.has(one.id) && one.id !== "decisions").map(one => one.id === "scope" && data.scopeDraft === undefined && (finished || approval.approved) ? { ...one, open: false } : one),
|
|
19857
20668
|
],
|
|
19858
20669
|
manage: [
|
|
19859
20670
|
...sectionParts.filter(one => MANAGE.has(one.id)),
|
|
19860
20671
|
{ id: "task-diagnostics", title: "Task options", html: optionsHtml, open: optionsOpen, count: null },
|
|
19861
20672
|
],
|
|
19862
20673
|
cancel: cancelForm === "" ? null : { html: cancelForm, open: data.cancelDraft !== undefined },
|
|
20674
|
+
journey: data.guide !== true || task.state === "cancelled" || data.status === undefined ? null : (() => {
|
|
20675
|
+
const planning = data.plan === "requested" && approveForm === "";
|
|
20676
|
+
const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
|
|
20677
|
+
return firstTaskJourney(read, scope !== null && approvalOf(scope).approved, task.state === "done");
|
|
20678
|
+
})(),
|
|
19863
20679
|
// "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
|
|
20680
|
+
thread,
|
|
20681
|
+
details,
|
|
20682
|
+
chatHref: data.csrf === "" ? null : taskChatHref(data.rootId ?? task.id),
|
|
19864
20683
|
everyTime: data.repo === null || data.csrf === "" ? null : (() => {
|
|
19865
20684
|
const starter = starterForWork(`${task.title}\n${scope?.goal ?? ""}`);
|
|
19866
20685
|
return { href: `/settings/flows?repo=${encodeURIComponent(data.repo)}&starter=${starter.id}#starter-${starter.id}`, starter: starter.name };
|
|
@@ -20403,7 +21222,7 @@ function pullRequestCardHtml(pr, csrf) {
|
|
|
20403
21222
|
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="run" value="${view.runId}">` +
|
|
20404
21223
|
`<p class="meta">${escape(view.mergeMethod === "squash" ? "Squash-merges" : view.mergeMethod === "rebase" ? "Rebase-merges" : "Merges")} PR #${view.prNumber} into ${escape(target)} and deletes its branch.</p>` +
|
|
20405
21224
|
`<label>Password<input type="password" name="token" autocomplete="current-password" required></label>` +
|
|
20406
|
-
`<button type="submit">Merge</button
|
|
21225
|
+
(view.fullCheck === null ? `<button type="submit">Merge</button>` : `<input type="hidden" name="anyway" value="1"><button type="submit" class="secondary">Merge anyway</button>`) + `</form>`;
|
|
20407
21226
|
return `<section class="card pull-request pull-request--${tone}" id="merge" data-pull-request="${escape(view.state)}">` +
|
|
20408
21227
|
`<div class="pull-request-head"><strong>${escape(view.label)}</strong>${link}</div>` +
|
|
20409
21228
|
`<p class="meta">${escape(view.detail)}${revision}</p>${merged}${act}</section>`;
|
|
@@ -20446,6 +21265,18 @@ const PHASE_WORDS = {
|
|
|
20446
21265
|
"capturing-evidence": "capturing evidence",
|
|
20447
21266
|
committing: "committing",
|
|
20448
21267
|
};
|
|
21268
|
+
/** An agent's phase on the home card, from the machine's own vocabulary. */
|
|
21269
|
+
function homePhaseWords(run) {
|
|
21270
|
+
if (run.role === "planner")
|
|
21271
|
+
return "Planning";
|
|
21272
|
+
if (run.role === "reviewer")
|
|
21273
|
+
return "Reviewing";
|
|
21274
|
+
if (run.role === "scout")
|
|
21275
|
+
return "Investigating";
|
|
21276
|
+
const words = { "agent-running": "Writing the change", "validating-handoff": "Checking its handoff", "capturing-evidence": "Saving its evidence",
|
|
21277
|
+
committing: "Committing", "verifying-proof": "Running the checks", "correcting-proof": "Correcting its evidence" };
|
|
21278
|
+
return run.phase === null ? "Starting" : words[run.phase] ?? "Working";
|
|
21279
|
+
}
|
|
20449
21280
|
function phaseWords(phase) {
|
|
20450
21281
|
return PHASE_WORDS[phase] ?? "the agent is working";
|
|
20451
21282
|
}
|
|
@@ -21102,6 +21933,34 @@ function evidenceBundleCard(view, run, publication = null, review = null) {
|
|
|
21102
21933
|
* the operator's acceptance alone — shared with the review cockpit's
|
|
21103
21934
|
* header chip so the two surfaces never name one state differently. */
|
|
21104
21935
|
/** The receipt's facts as the shared projection reads them. */
|
|
21936
|
+
/** A result's follow-ups: checks run on its commit since it finished, and its tests task. */
|
|
21937
|
+
function followUpsFor(store, evidenceRoot, run, now) {
|
|
21938
|
+
if (run.role !== "builder" || run.finishedAt === null || run.headRevision === null)
|
|
21939
|
+
return null;
|
|
21940
|
+
const repo = store.refById(run.taskRef)?.repo ?? null;
|
|
21941
|
+
const tests = store.handle.prepare("SELECT outcome FROM action_ledger WHERE run_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(run.id, ADD_TESTS_ACTION);
|
|
21942
|
+
return { repo, checks: followUpChecksOf(store, run.id, now, evidenceRoot), testsTask: tests === undefined ? null : String(tests["outcome"]),
|
|
21943
|
+
quick: repo !== null && liveQuickCommand(store, repo) !== null, full: repo !== null && store.liveVerifyCommand(repo) !== null };
|
|
21944
|
+
}
|
|
21945
|
+
const FOLLOW_UP_WORDS = { waiting: "waiting for a worker", running: "running", passed: "passed", failed: "failed", "not-run": "didn't run" };
|
|
21946
|
+
/** Run checks and Add tests, under the result's Checks: what ran since, then the two acts. */
|
|
21947
|
+
function followUpsHtml(followUps, runId, o) {
|
|
21948
|
+
const rows = followUps.checks.map(one => `<li data-follow-up-check="${one.state}">${escape(CHECK_LEVEL_WORDS[one.level])} checks ${escape(FOLLOW_UP_WORDS[one.state])}` +
|
|
21949
|
+
`${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape(one.head.slice(0, 7))}</span>` +
|
|
21950
|
+
`<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
|
|
21951
|
+
`${one.logArtifactId === null ? "" : ` <a href="/r/${runId}/evidence/${one.logArtifactId}">Log</a>`}</li>`).join("");
|
|
21952
|
+
const hidden = `<input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">`;
|
|
21953
|
+
const canCheck = followUps.quick || followUps.full;
|
|
21954
|
+
const checkActs = !canCheck || o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/checks" class="follow-up-act">${hidden}` +
|
|
21955
|
+
(followUps.quick && followUps.full ? `<button type="submit" name="level" value="quick">Run quick checks</button><button type="submit" name="level" value="full" class="secondary">Run full checks</button>`
|
|
21956
|
+
: `<button type="submit" name="level" value="${followUps.quick ? "quick" : "full"}">Run checks</button>`) + `</form>`;
|
|
21957
|
+
const tests = followUps.testsTask !== null ? `<p class="meta">Tests task: <a href="${taskHref(followUps.testsTask)}">${escape(followUps.testsTask)}</a></p>`
|
|
21958
|
+
: o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/add-tests" class="follow-up-act">${hidden}<button type="submit" class="secondary">Add tests</button></form>`;
|
|
21959
|
+
return `<section class="result-section follow-ups" id="follow-ups" data-follow-ups><strong>Follow-ups</strong>` +
|
|
21960
|
+
(rows === "" ? "" : `<ul class="follow-up-list">${rows}</ul>`) +
|
|
21961
|
+
(canCheck || followUps.repo === null ? "" : `<p class="meta">No approved check to run. <a href="/settings/checks?repo=${encodeURIComponent(followUps.repo)}">Set one up</a>.</p>`) +
|
|
21962
|
+
(checkActs === "" && tests === "" ? "" : `<div class="follow-up-acts">${checkActs}${tests}</div>`) + `</section>`;
|
|
21963
|
+
}
|
|
21105
21964
|
function receiptStatusOf(view, review = view.review) {
|
|
21106
21965
|
// The evidence health rides the status (repair 2026-09-14): a result
|
|
21107
21966
|
// whose stored records are damaged is never called ready on the strength
|
|
@@ -21222,7 +22081,9 @@ function resultPanelParts(detail, o) {
|
|
|
21222
22081
|
const { run, receipt, proof, terminal, handoff } = detail;
|
|
21223
22082
|
const facts = receipt.facts;
|
|
21224
22083
|
const current = detail.assignment?.receipt?.runId === run.id ? detail.assignment : null;
|
|
21225
|
-
const
|
|
22084
|
+
const followUps = detail.followUps ?? null;
|
|
22085
|
+
const resultLinks = { result: o.hrefFor("summary"), checks: o.hrefFor("checks"), pullRequest: `${taskHref(detail.rootId ?? detail.taskId)}#merge`,
|
|
22086
|
+
...(followUps !== null && (followUps.quick || followUps.full) && o.csrf !== "" ? { runChecks: `${o.hrefFor("checks")}#follow-ups` } : {}) };
|
|
21226
22087
|
const presentation = current == null ? null : assignmentPresentationOf(current, { additionalAttention: [...facts.evidenceProblems, ...receipt.caveats],
|
|
21227
22088
|
...(detail.publication === null ? {} : { pullRequest: pullRequestFactOf({ ...detail.publication, lastCheckState: detail.ciFailing ? "failing" : detail.publication.lastCheckState }) }),
|
|
21228
22089
|
evidence: { damaged: facts.evidenceHealth.damaged, missing: facts.evidenceHealth.missing, shortened: facts.evidenceHealth.shortened }, links: resultLinks });
|
|
@@ -21265,6 +22126,16 @@ function resultPanelParts(detail, o) {
|
|
|
21265
22126
|
if (current == null)
|
|
21266
22127
|
attention.push(...receipt.caveats.filter(caveat => !historicalAssessmentReason(caveat) && !attention.some(problem => problem.includes(caveat))));
|
|
21267
22128
|
attention.push(...(handoff?.followUps ?? []).map(one => `Follow-up: ${one}`));
|
|
22129
|
+
// The automatic review: a HIGH that did not send the work back, or a review
|
|
22130
|
+
// that could not finish, is the person's to see. (Said once when the
|
|
22131
|
+
// current assignment already carries it.)
|
|
22132
|
+
const automatic = detail.automaticReview ?? null;
|
|
22133
|
+
if (automatic !== null && automatic.sentBackAs === null) {
|
|
22134
|
+
const reviewed = [...(automatic.state === "not-reviewed" ? [`Not reviewed: ${automatic.reason ?? "the automatic review did not finish"}.`] : []), ...automatic.high.map(one => `Review: ${findingWords(one)}`)];
|
|
22135
|
+
for (const line of reviewed)
|
|
22136
|
+
if (!attention.includes(line))
|
|
22137
|
+
attention.push(line);
|
|
22138
|
+
}
|
|
21268
22139
|
// Publication risks stay in the open (repair 2026-09-14); the routine
|
|
21269
22140
|
// publication fact lives with the build details below.
|
|
21270
22141
|
// With the shared status, the pull request is its own quiet row (its reason under Details).
|
|
@@ -21339,6 +22210,10 @@ function resultPanelParts(detail, o) {
|
|
|
21339
22210
|
];
|
|
21340
22211
|
if (agentAccount.length > 0)
|
|
21341
22212
|
summaryParts.push(`<details class="result-notes" data-result-notes-agent><summary>What the agent reported</summary>${agentAccount.join("")}</details>`);
|
|
22213
|
+
// MEDIUM and LOW never block: suggested follow-ups, on request.
|
|
22214
|
+
if (automatic !== null && automatic.followUps.length > 0) {
|
|
22215
|
+
summaryParts.push(`<details class="result-notes" data-review-followups="${automatic.followUps.length}"><summary>Suggested follow-ups · ${automatic.followUps.length}</summary><ul class="result-changes">${automatic.followUps.map(one => `<li><span class="badge">${one.severity === "MEDIUM" ? "Medium" : "Low"}</span> <span class="mono">${escape(`${one.file}:${one.line}`)}</span> ${escape(one.scenario)}</li>`).join("")}</ul></details>`);
|
|
22216
|
+
}
|
|
21342
22217
|
const evidenceSources = [
|
|
21343
22218
|
...(report !== null ? [report.ok ? `verified report${report.truncated ? " (shortened)" : ""}` : "report (unverifiable)"] : []),
|
|
21344
22219
|
...(patch !== null ? [patchOk ? `sealed diff${patch.truncated ? " (shortened)" : ""}` : "diff (unverifiable)"] : []),
|
|
@@ -21463,7 +22338,7 @@ function resultPanelParts(detail, o) {
|
|
|
21463
22338
|
}
|
|
21464
22339
|
if (proof.proofProblem !== null)
|
|
21465
22340
|
checkParts.push(`<p class="problem">Verification details are unavailable: ${escape(proof.proofProblem)}</p>`);
|
|
21466
|
-
checkParts.push(proof.checkLog === null ? `<p class="meta" data-cockpit-source="machine"
|
|
22341
|
+
checkParts.push(proof.checkLog === null ? `<p class="meta" data-cockpit-source="machine">${current?.receipt?.checks.level === "off" ? "Checks were off for this build." : "No automated check was configured for this build."}</p>` : checkLogHtml(proof.checkLog, runId, ' data-cockpit-source="machine"'));
|
|
21467
22342
|
checkParts.push(proof.proof === null || proof.proof.checks.length === 0
|
|
21468
22343
|
? directAssessment ? "" : `<p class="meta" data-cockpit-source="agent">The agent reported no checks.</p>`
|
|
21469
22344
|
: `<details class="cockpit-proof-group" data-cockpit-source="agent"><summary>Agent checks · ${proof.proof.checks.length}</summary><div class="result-section"><ul>` +
|
|
@@ -21481,6 +22356,8 @@ function resultPanelParts(detail, o) {
|
|
|
21481
22356
|
? directAssessment ? "" : `<p class="meta" data-cockpit-source="caveats">No caveats were reported.</p>`
|
|
21482
22357
|
: `<details class="cockpit-proof-group" data-cockpit-source="caveats"><summary>Agent caveats · ${receipt.caveats.length}</summary><ul>${receipt.caveats.map(one => `<li>${escape(one)}</li>`).join("")}</ul></details>`);
|
|
21483
22358
|
}
|
|
22359
|
+
if (followUps !== null && run.role === "builder")
|
|
22360
|
+
checkParts.push(followUpsHtml(followUps, runId, o));
|
|
21484
22361
|
// The handoff's own account of its checks — the agent's words, labeled
|
|
21485
22362
|
// as such, whether or not a proof exists.
|
|
21486
22363
|
if (!directAssessment && handoff !== null && handoff.verification.length > 0)
|
|
@@ -22001,6 +22878,13 @@ function telegramDeliveryWords(store, bot) {
|
|
|
22001
22878
|
return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
|
|
22002
22879
|
return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
|
|
22003
22880
|
}
|
|
22881
|
+
/** The projects this person can see, by name, and whether they muted each one's pings. */
|
|
22882
|
+
function notificationProjects(store, account) {
|
|
22883
|
+
const muted = new Set(store.mutedProjects(account));
|
|
22884
|
+
return store.listProjects().filter(one => store.accountCanAccess(account, one.path))
|
|
22885
|
+
.map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
|
|
22886
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
22887
|
+
}
|
|
22004
22888
|
function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null, firstResult = null, chatNotices = null) {
|
|
22005
22889
|
const permissionCard = permissionDefault === null
|
|
22006
22890
|
? ""
|
|
@@ -22045,6 +22929,14 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
|
|
|
22045
22929
|
`<p class="meta">One message: what finished, what waits, what failed.</p>`,
|
|
22046
22930
|
`<button type="submit">Save</button>`,
|
|
22047
22931
|
`</form>`,
|
|
22932
|
+
...((chatNotices.projects ?? []).length === 0 ? [] : [
|
|
22933
|
+
"<h3>Projects</h3>",
|
|
22934
|
+
`<ul class="card">${chatNotices.projects.map(one => `<li><form method="post" action="/settings/notifications/mute">` +
|
|
22935
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="repo" value="${escape(one.repo)}">` +
|
|
22936
|
+
(one.muted ? "" : `<input type="hidden" name="pings" value="on">`) +
|
|
22937
|
+
`${escape(one.name)} <span class="meta">· ${one.muted ? "muted" : "pings on"}</span> <button type="submit">${one.muted ? "Unmute" : "Mute"}</button></form></li>`).join("")}</ul>`,
|
|
22938
|
+
`<p class="meta">Muted projects still show in Tasks and the evening digest.</p>`,
|
|
22939
|
+
]),
|
|
22048
22940
|
].join("\n");
|
|
22049
22941
|
const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
|
|
22050
22942
|
? ""
|
|
@@ -22326,6 +23218,7 @@ function settingsTiles() {
|
|
|
22326
23218
|
const SETTINGS_TILE_ICONS = [
|
|
22327
23219
|
["/settings/flows", "Flows", `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M10 6.5h4a3 3 0 0 1 3 3V14"/>`],
|
|
22328
23220
|
["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
|
|
23221
|
+
["/settings/lead", "Lead", `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8M8 13h5"/>`],
|
|
22329
23222
|
["/settings/models", "Models", `<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>`],
|
|
22330
23223
|
["/settings/skills", "Skills", `<path d="m12 3 1.9 5.8L20 10l-5 3.6L16.8 20 12 16.4 7.2 20 9 13.6 4 10l6.1-1.2z"/>`],
|
|
22331
23224
|
["/settings/tools", "Tools", `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`],
|