toolroll 0.9.5 → 0.9.7
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 +49 -0
- package/dist/actor.d.ts +21 -0
- package/dist/actor.js +48 -0
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-presentation.d.ts +13 -2
- package/dist/assignment-presentation.js +22 -9
- package/dist/assignment-ui.d.ts +10 -15
- package/dist/assignment-ui.js +19 -18
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +62 -13
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +29 -24
- package/dist/browser-workspace.d.ts +120 -12
- package/dist/builder.d.ts +1 -0
- package/dist/builder.js +33 -13
- package/dist/chat-acceptance.js +2 -2
- package/dist/chat-actions.js +2 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-quiet.js +30 -19
- package/dist/chat-task-actions.d.ts +13 -0
- package/dist/chat-task-actions.js +19 -1
- package/dist/check-levels-ui.d.ts +21 -0
- package/dist/check-levels-ui.js +70 -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 +4 -0
- package/dist/demo-chat.d.ts +0 -5
- package/dist/demo-chat.js +14 -3
- package/dist/dispatch.js +7 -2
- package/dist/dispose.d.ts +8 -0
- package/dist/dispose.js +7 -1
- 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/lead-context.js +6 -2
- package/dist/lead-status.d.ts +7 -0
- package/dist/lead-status.js +11 -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/operate.d.ts +4 -1
- package/dist/operate.js +601 -85
- 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/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/scope.js +11 -0
- package/dist/serve.d.ts +211 -2
- package/dist/serve.js +1070 -204
- package/dist/store.d.ts +76 -3
- package/dist/store.js +311 -22
- package/dist/surface.js +31 -8
- 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 +186 -0
- package/dist/task-status.js +335 -0
- package/dist/team-cli.js +3 -0
- package/dist/telegram-progress.js +62 -100
- package/dist/telegram-status.js +21 -8
- package/dist/verification-evidence.js +21 -1
- package/dist/webhooks.js +5 -0
- package/dist/work-index.js +60 -11
- package/dist/work-summary.d.ts +0 -4
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +10 -0
- package/dist/workspace-ui.js +33 -2
- 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,15 +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';
|
|
75
|
+
import { assignmentStageOf, pullRequestFactOf, stageOfCode, stageOfDispatch, statusDetailsHtml, statusIconSvg, statusWhyHtml, taskStatusOf, TASK_STATUS_CSS } from './task-status.js';
|
|
67
76
|
import { assignmentPresentationOf, historicalAssessmentReason, shortenedMaterialReason } from './assignment-presentation.js';
|
|
68
77
|
import { ledgerBody } from "./ledger-view.js";
|
|
69
78
|
import { EVIDENCE_PACK_CSS, evidencePack, evidencePackHtml, exportDay, ledgerExportChunks } from "./evidence-pack.js";
|
|
@@ -125,7 +134,7 @@ import { projectAuthority } from "./project-access.js";
|
|
|
125
134
|
* repo, only runs whose task belongs to that repo (or to no repo yet).
|
|
126
135
|
*/
|
|
127
136
|
import { createConnectionChecker } from "./provider-connection.js";
|
|
128
|
-
import { liftAuthPause, noteSignInProbe, signInNotices } from "./provider-auth.js";
|
|
137
|
+
import { liftAuthPause, noteSignInProbe, providerName, signInNotices } from "./provider-auth.js";
|
|
129
138
|
import { openRouterModelsCache, openRouterPickerScript } from "./openrouter-models.js";
|
|
130
139
|
import { checkModels, isNewModel, livePin, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "./model-catalog.js";
|
|
131
140
|
import { MODELS_CSS, modelsHtml, modelsScript } from "./models-ui.js";
|
|
@@ -152,7 +161,7 @@ import { starterRecipes, savedRecipes, findRecipe, importRecipe, exportRecipe, c
|
|
|
152
161
|
import { recipeFromForm, recipeLibraryHtml, recipeEditorHtml, workflowPreviewHtml, recipeScript, RECIPE_CSS, recipeDefinitionPreviewHtml, recipeRunHtml, recipeAnswersFromForm } from "./recipe-ui.js";
|
|
153
162
|
import { EVIDENCE_CAPS, readVerifiedArtifact, readVerifiedReport, readVerifiedProofForRun, storeEvidence, writeEvidenceFile, scanForSecrets } from "./evidence.js";
|
|
154
163
|
import { GOAL_ASSESSMENT_PENDING, reviewConflict, manualReviewOnly, manualReviewCriterionOf, personCheckWords, plainReasonWords, dispatchStatusToken, passFraction, semanticCoverage, coverageWords, coverageStateWords } from "./proof.js";
|
|
155
|
-
import { WORK_VIEWS, REVIEW_TOKENS, parseWorkView, resultStatusOf, receiptHeadingOf, receiptPublicationWords, reviewFactsOf, workStatusOf, primaryDestinationOf, needsPerson, dispatchActionLabel, } from "./workspace-ui.js";
|
|
164
|
+
import { WORK_VIEWS, REVIEW_TOKENS, parseWorkView, resultStatusOf, resultHeadlineOf, receiptHeadingOf, receiptPublicationWords, reviewFactsOf, workStatusOf, primaryDestinationOf, needsPerson, dispatchActionLabel, } from "./workspace-ui.js";
|
|
156
165
|
import { PRICED_BUILD_MODELS } from "./pricing.js";
|
|
157
166
|
import { RESULT_TABS, RESULT_REVIEW_SCRIPT, isRevisionFeedback, evidenceResultStatusOf, evidenceShortenedWords, parseResultTab, resultLeadOf, evidenceProblemDetailsOf, evidenceHealthOf, resultFactsAttributes, resultFactsAttributeMap, resultReturnTarget, commentSourceKey, REQUEST_TOKEN, revisionBatchOf, parseRevisionBatch, revisionSourceOf, } from "./result-review.js";
|
|
158
167
|
import { parseReport } from "./scout-report.js";
|
|
@@ -176,7 +185,7 @@ import { verifiedAuthor, LEAD_THREAD, isDigestTime } from "./store.js";
|
|
|
176
185
|
import { digestTimes } from "./digest-times.js";
|
|
177
186
|
import { updateRepos, addRepos, removeRepos } from "./repos.js";
|
|
178
187
|
import { run as execRun } from "./exec.js";
|
|
179
|
-
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";
|
|
180
189
|
/** A user agent, reduced to safe display words — never echoed raw. */
|
|
181
190
|
function oneLineUa(raw) {
|
|
182
191
|
const text = Array.isArray(raw) ? (raw[0] ?? "") : (raw ?? "");
|
|
@@ -224,6 +233,7 @@ import { RETENTION_KINDS, lastSweepAt, parsePeriod, retentionPlan } from "./rete
|
|
|
224
233
|
import { EXPORT_CSS, dataExportHtml } from "./export-ui.js";
|
|
225
234
|
import { buildExport, exportZip } from "./export.js";
|
|
226
235
|
import { PROJECT_DELETE_CSS, projectDeleteConfirmHtml, projectSettingsHtml } from "./project-delete-ui.js";
|
|
236
|
+
import { parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, projectConcurrency, saveProjectConcurrency } from "./project-concurrency.js";
|
|
227
237
|
import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
|
|
228
238
|
import { POLICY_CSS, policyHtml } from "./policy-ui.js";
|
|
229
239
|
import { checkPolicy, parseList, policyParts } from "./policy.js";
|
|
@@ -251,6 +261,13 @@ import { PACKAGE_VERSION } from "./version.js";
|
|
|
251
261
|
import { updateNoticeWords } from "./update-notice.js";
|
|
252
262
|
import { whenHtml } from "./when-html.js";
|
|
253
263
|
const SESSION_COOKIE = "standing-orders_session";
|
|
264
|
+
/** Where `up`'s one-time sign-in link points, and how long it works. */
|
|
265
|
+
export const SIGN_IN_LINK_PATH = "/login/once/";
|
|
266
|
+
export const SIGN_IN_LINK_MS = 10 * 60_000;
|
|
267
|
+
/** The installation fact that the lead was turned on by default, once (its value: the provider). */
|
|
268
|
+
const LEAD_BY_DEFAULT_FACT = "lead-on-by-default";
|
|
269
|
+
/** The installation fact that the phone card was put away. */
|
|
270
|
+
const PHONE_CARD_FACT = "phone-card-dismissed";
|
|
254
271
|
const BODY_CAP = 16 * 1024;
|
|
255
272
|
// URL encoding can triple UTF-8 bytes. Admit the existing bounded task
|
|
256
273
|
// fields (including paths and rubric) before canonical text validation.
|
|
@@ -514,6 +531,30 @@ export function createDecisionServer(options) {
|
|
|
514
531
|
const ssoOffer = () => { const settings = ssoSettings(); return settings === null ? null : { label: settings.label, operatorsOnly: settings.passwords === "operators" }; };
|
|
515
532
|
/** A step-up within this long of the provider checking someone needs no password. */
|
|
516
533
|
const SSO_FRESH_MS = 10 * 60_000;
|
|
534
|
+
// The one-time sign-in link `up` opens in the browser: single use, ten minutes, from this computer only. Only a hash
|
|
535
|
+
// of the code is kept, in memory; the code itself is never written to a log or the ledger.
|
|
536
|
+
const signInLinks = new Map();
|
|
537
|
+
const linkKey = (code) => createHash("sha256").update(code).digest("hex");
|
|
538
|
+
function mintSignInLink(account) {
|
|
539
|
+
const known = store.accountOf(account);
|
|
540
|
+
if (known === null || known.role !== "approver" || known.revokedAt !== null)
|
|
541
|
+
return null;
|
|
542
|
+
for (const [key, one] of signInLinks)
|
|
543
|
+
if (one.expires < Date.now())
|
|
544
|
+
signInLinks.delete(key);
|
|
545
|
+
while (signInLinks.size >= 20)
|
|
546
|
+
signInLinks.delete(signInLinks.keys().next().value);
|
|
547
|
+
const code = randomBytes(32).toString("base64url");
|
|
548
|
+
signInLinks.set(linkKey(code), { account, expires: Date.now() + SIGN_IN_LINK_MS });
|
|
549
|
+
return `${SIGN_IN_LINK_PATH}${code}`;
|
|
550
|
+
}
|
|
551
|
+
/** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
|
|
552
|
+
const fromThisComputer = (request) => {
|
|
553
|
+
const peer = request.socket.remoteAddress ?? "";
|
|
554
|
+
const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
|
|
555
|
+
return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
|
|
556
|
+
&& /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
|
|
557
|
+
};
|
|
517
558
|
async function providerFor(issuer) {
|
|
518
559
|
if (ssoProvider !== null && ssoProvider.issuer === issuer && Date.now() - ssoProvider.at < 10 * 60_000)
|
|
519
560
|
return { ok: true, provider: ssoProvider.provider };
|
|
@@ -606,7 +647,18 @@ export function createDecisionServer(options) {
|
|
|
606
647
|
const projectViewOf = (repo, who) => ({
|
|
607
648
|
repo, name: projectName(repo), holdings: projectHoldings(store, repo), running: projectRunning(store, repo, clock()),
|
|
608
649
|
canDelete: who.via === "cookie" && store.isInstanceOperator(who.name),
|
|
650
|
+
builds: projectBuildsOf(repo, who),
|
|
609
651
|
});
|
|
652
|
+
/** Settings → Project → Builds at once: the saved number, its workers' capacity, and what builds now. */
|
|
653
|
+
const projectBuildsOf = (repo, who) => {
|
|
654
|
+
const capacities = store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo)).map(one => one.capacity);
|
|
655
|
+
return {
|
|
656
|
+
setting: (() => { const file = store.databaseFile(); return file === null ? PROJECT_CONCURRENCY_DEFAULT : projectConcurrency(file, repo); })(),
|
|
657
|
+
capacity: capacities.length === 0 ? null : Math.max(...capacities),
|
|
658
|
+
building: store.runningBuildsByRepo(clock()).get(repo) ?? 0,
|
|
659
|
+
canChange: who.via === "cookie" && who.role === "approver" && store.accountCanAccess(who.name, repo),
|
|
660
|
+
};
|
|
661
|
+
};
|
|
610
662
|
/** Codex's own MCP servers for a project (`codex mcp list --json`), for "Found on this computer"; null when Codex can't say. */
|
|
611
663
|
const codexServers = async (repo) => {
|
|
612
664
|
try {
|
|
@@ -694,7 +746,7 @@ export function createDecisionServer(options) {
|
|
|
694
746
|
const server = createServer((request, response) => {
|
|
695
747
|
void handle(request, response).catch(error => {
|
|
696
748
|
// Every unhandled error is logged (v99): the path and the message, never the request's body or query.
|
|
697
|
-
logEvent("error", "serve.error", { method: request.method, path: new URL(request.url ?? "/", "http://placeholder").pathname, error: error instanceof Error ? error.message : String(error) });
|
|
749
|
+
logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
|
|
698
750
|
if (envValue(process.env, "SERVE_DEBUG") === "1")
|
|
699
751
|
console.error("SERVE ERROR:", error);
|
|
700
752
|
if (!response.headersSent) {
|
|
@@ -729,15 +781,43 @@ export function createDecisionServer(options) {
|
|
|
729
781
|
const cookieSecure = publicOrigin === null ? "" : "; Secure";
|
|
730
782
|
/** Where Google may send someone back to (v89): the public https address, or this computer's own. */
|
|
731
783
|
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;
|
|
784
|
+
/** This computer's tailnet names, the last ones read (onboarding). */
|
|
785
|
+
let tailnet = [];
|
|
786
|
+
if (options.tailnetNames !== undefined) {
|
|
787
|
+
const readTailnet = options.tailnetNames;
|
|
788
|
+
const refresh = () => { void readTailnet().then(names => { tailnet = names; }, () => { }); };
|
|
789
|
+
refresh();
|
|
790
|
+
const timer = setInterval(refresh, 5 * 60_000);
|
|
791
|
+
timer.unref?.();
|
|
792
|
+
server.on("close", () => clearInterval(timer));
|
|
793
|
+
}
|
|
794
|
+
const servedPort = () => {
|
|
795
|
+
const address = server.address();
|
|
796
|
+
return typeof address === "object" && address !== null ? address.port : null;
|
|
797
|
+
};
|
|
732
798
|
/** The names this server answers as. Anything else is a rebind, refused. */
|
|
733
799
|
const allowedHost = (host) => {
|
|
734
800
|
if (host === undefined)
|
|
735
801
|
return false;
|
|
736
|
-
const
|
|
737
|
-
const
|
|
738
|
-
const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
|
|
802
|
+
const port = servedPort();
|
|
803
|
+
const locals = port === null ? [] : [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`, ...tailnet.map(name => `${name}:${port}`)];
|
|
739
804
|
return [...locals, ...(options.allowedHosts ?? []), ...(publicOrigin === null ? [] : [publicOrigin.host])].includes(host);
|
|
740
805
|
};
|
|
806
|
+
/** The exact command that starts this console again as it runs now, plus a listening address or one more allowed host. */
|
|
807
|
+
const consoleCommand = (change) => {
|
|
808
|
+
const port = servedPort() ?? 4180;
|
|
809
|
+
const address = server.address();
|
|
810
|
+
const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
|
|
811
|
+
const host = change.host ?? (bound === "127.0.0.1" || bound === "::1" ? null : bound === "::" ? "0.0.0.0" : bound);
|
|
812
|
+
const allowed = [...(options.allowedHosts ?? []), ...(change.allow === undefined ? [] : [change.allow])];
|
|
813
|
+
return [`toolroll ${options.upConsole === true ? "up" : "serve"}`, ...(host === null ? [] : [`--host ${host}`]), ...(port === 4180 ? [] : [`--port ${port}`]),
|
|
814
|
+
...(allowed.length === 0 ? [] : [`--allow-host ${allowed.join(",")}`])].join(" ");
|
|
815
|
+
};
|
|
816
|
+
/** The wrong-host page's facts: the address opened, where the console answers, and the exact command that admits it. */
|
|
817
|
+
const wrongHost = (host) => {
|
|
818
|
+
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;
|
|
819
|
+
return { opened, served: `127.0.0.1:${servedPort() ?? 4180}`, command: opened === null ? null : consoleCommand({ allow: opened }) };
|
|
820
|
+
};
|
|
741
821
|
/**
|
|
742
822
|
* The one gate every POST passes — parse elsewhere, authorize here. A
|
|
743
823
|
* refusal names its status; null means proceed. Duplicated security fields
|
|
@@ -1064,7 +1144,7 @@ export function createDecisionServer(options) {
|
|
|
1064
1144
|
if (hook.pathname.startsWith("/hooks/"))
|
|
1065
1145
|
return flowHook(request, response, hook);
|
|
1066
1146
|
if (!allowedHost(request.headers.host)) {
|
|
1067
|
-
return respond(response, 421, "text/
|
|
1147
|
+
return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
|
|
1068
1148
|
}
|
|
1069
1149
|
const url = new URL(request.url ?? "/", "http://placeholder");
|
|
1070
1150
|
// A token in a URL is a token in history, logs, and referers. Refused
|
|
@@ -1146,6 +1226,33 @@ export function createDecisionServer(options) {
|
|
|
1146
1226
|
}
|
|
1147
1227
|
return page(response, 200, loginPage(null, loginReturn(url.searchParams.get("return")), ssoOffer()));
|
|
1148
1228
|
}
|
|
1229
|
+
// The one-time link `up` opens (onboarding): it signs the browser on this computer in and lands on Chat. Spent on
|
|
1230
|
+
// the first visit whatever happens; the ledger notes the sign-in, never the link.
|
|
1231
|
+
if (url.pathname.startsWith(SIGN_IN_LINK_PATH) && method === "GET") {
|
|
1232
|
+
const code = url.pathname.slice(SIGN_IN_LINK_PATH.length);
|
|
1233
|
+
const held = /^[A-Za-z0-9_-]{43}$/.test(code) ? signInLinks.get(linkKey(code)) : undefined;
|
|
1234
|
+
if (held !== undefined)
|
|
1235
|
+
signInLinks.delete(linkKey(code));
|
|
1236
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
1237
|
+
if (!fromThisComputer(request)) {
|
|
1238
|
+
recordSignIn(held?.account ?? "unknown account", "sign-in refused", "one-time link", "opened from another device");
|
|
1239
|
+
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()));
|
|
1240
|
+
}
|
|
1241
|
+
if (held === undefined || held.expires < Date.now()) {
|
|
1242
|
+
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()));
|
|
1243
|
+
}
|
|
1244
|
+
const account = store.accountOf(held.account);
|
|
1245
|
+
if (account === null || account.role !== "approver" || account.revokedAt !== null)
|
|
1246
|
+
return page(response, 403, loginPage("That account can't sign in here any more.", "/chat", ssoOffer()));
|
|
1247
|
+
if (!passwordAllowed(held.account))
|
|
1248
|
+
return page(response, 403, loginPage(`Sign in with ${ssoSettings()?.label ?? "your identity provider"}.`, "/chat", ssoOffer()));
|
|
1249
|
+
const id = randomBytes(32).toString("hex");
|
|
1250
|
+
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(),
|
|
1251
|
+
project: defaultProject, projectRevision: 1 });
|
|
1252
|
+
recordSignIn(held.account, "signed in", "one-time link");
|
|
1253
|
+
response.setHeader("Set-Cookie", `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/${cookieSecure}`);
|
|
1254
|
+
return redirect(response, "/chat");
|
|
1255
|
+
}
|
|
1149
1256
|
// v100: sign-in with the identity provider. ?reauth=1 asks it to check the person again (a step-up);
|
|
1150
1257
|
// ?link=1 links the signed-in account to it. The browser that starts it holds the visit's state.
|
|
1151
1258
|
if (url.pathname === "/login/sso" && method === "GET") {
|
|
@@ -1425,7 +1532,7 @@ export function createDecisionServer(options) {
|
|
|
1425
1532
|
if (method === "GET" || method === "POST")
|
|
1426
1533
|
return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
|
|
1427
1534
|
const taskTextForm = url.pathname === "/tasks/add" || /^\/t\/[^/]+\/scope$/.test(url.pathname);
|
|
1428
|
-
const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
|
|
1535
|
+
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;
|
|
1429
1536
|
const target = actionTarget(url, who, request, body);
|
|
1430
1537
|
const execute = async () => {
|
|
1431
1538
|
if (!projectRequestAllowed(url, who, request, response))
|
|
@@ -1590,8 +1697,8 @@ export function createDecisionServer(options) {
|
|
|
1590
1697
|
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"]);
|
|
1591
1698
|
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)$");
|
|
1592
1699
|
const resource = request.method === "GET"
|
|
1593
|
-
? /^\/(?: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)
|
|
1594
|
-
: /^\/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);
|
|
1700
|
+
? /^\/(?: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)
|
|
1701
|
+
: /^\/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);
|
|
1595
1702
|
if (!(request.method === "GET" ? read : write).has(path) && task === null && !resource) {
|
|
1596
1703
|
refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
|
|
1597
1704
|
return false;
|
|
@@ -1616,6 +1723,20 @@ export function createDecisionServer(options) {
|
|
|
1616
1723
|
return true;
|
|
1617
1724
|
}
|
|
1618
1725
|
// ---- reads ---------------------------------------------------------------
|
|
1726
|
+
/** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
|
|
1727
|
+
function sendGalleryUse(response, who, template, projects, repo, given, name, notice) {
|
|
1728
|
+
const answers = given ?? galleryDefaults(store, template, repo);
|
|
1729
|
+
let preview = null, problem = notice;
|
|
1730
|
+
try {
|
|
1731
|
+
preview = previewGallery(store, template, repo, answers, who.name, clock());
|
|
1732
|
+
}
|
|
1733
|
+
catch (error) {
|
|
1734
|
+
problem = error instanceof Error ? error.message : "That can't be made here.";
|
|
1735
|
+
}
|
|
1736
|
+
const html = galleryUseHtml({ template, projects: projects.map(path => ({ path, name: projectName(path) })), repo, answers, name, preview, problem,
|
|
1737
|
+
csrf: who.via === "cookie" ? who.session.csrf : "", diagram: preview?.built.definition ?? galleryDiagram(template) });
|
|
1738
|
+
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") }));
|
|
1739
|
+
}
|
|
1619
1740
|
async function handleGet(url, who, request, response) {
|
|
1620
1741
|
const now = clock();
|
|
1621
1742
|
let project = projectOf(who, request);
|
|
@@ -1715,7 +1836,9 @@ export function createDecisionServer(options) {
|
|
|
1715
1836
|
url.pathname !== "/menu" &&
|
|
1716
1837
|
url.pathname !== "/recipes" &&
|
|
1717
1838
|
// A flow names its own project; the list spans every project.
|
|
1718
|
-
url.pathname !== "/flows" && !/^\/flows\/[0-9]{1,15}(\/insights|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
|
|
1839
|
+
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) &&
|
|
1840
|
+
// The gallery (Flows → New) asks for its project on each template's page.
|
|
1841
|
+
url.pathname !== "/flows/new" && !/^\/flows\/new\/[a-z-]{1,40}$/.test(url.pathname) &&
|
|
1719
1842
|
// Teammates (v92) name their own project, like flows.
|
|
1720
1843
|
!url.pathname.startsWith("/teammates") &&
|
|
1721
1844
|
// New work names its project in the form (a dropdown of known
|
|
@@ -1728,6 +1851,7 @@ export function createDecisionServer(options) {
|
|
|
1728
1851
|
url.pathname !== "/projects/browse" && url.pathname !== "/projects/github" && url.pathname !== "/workbench" &&
|
|
1729
1852
|
url.pathname !== "/fleet" &&
|
|
1730
1853
|
url.pathname !== "/chat" && url.pathname !== "/chat/mate/status" && url.pathname !== "/chat/task-status" && url.pathname !== "/chat/stream" &&
|
|
1854
|
+
url.pathname !== "/lead/status" && url.pathname !== "/onboarding/phone/dismiss" &&
|
|
1731
1855
|
!url.pathname.startsWith("/chat/demo/") &&
|
|
1732
1856
|
!/^\/chat\/action\/[0-9]{1,15}$/.test(url.pathname) &&
|
|
1733
1857
|
!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" &&
|
|
@@ -1880,7 +2004,7 @@ export function createDecisionServer(options) {
|
|
|
1880
2004
|
const cancelled = store
|
|
1881
2005
|
.listCancelledBlockersScoped(project, 10, admission)
|
|
1882
2006
|
.filter(one => visible(one.repo) && visible(one.blockerRepo));
|
|
1883
|
-
|
|
2007
|
+
const inboxData = {
|
|
1884
2008
|
csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
1885
2009
|
revision: who.via === "cookie" ? who.session.projectRevision : 0,
|
|
1886
2010
|
rollup,
|
|
@@ -1922,7 +2046,26 @@ export function createDecisionServer(options) {
|
|
|
1922
2046
|
return { answering: answering.length, registered: runners.length, lastHeard };
|
|
1923
2047
|
})(),
|
|
1924
2048
|
now,
|
|
1925
|
-
|
|
2049
|
+
// Console v2: results ready to review and work running now, from the same admitted work index.
|
|
2050
|
+
...(() => {
|
|
2051
|
+
const row = (one) => ({ taskId: one.rootId, title: one.title, detail: one.status.detail, repo: one.repo });
|
|
2052
|
+
const needsYou = workIndexPage(store, now, workAccess(), { view: "needs-you", limit: 40, project });
|
|
2053
|
+
const runningNow = workIndexPage(store, now, workAccess(), { view: "running", limit: 40, project });
|
|
2054
|
+
return { ready: needsYou.items.filter(one => one.assignmentState === "ready-to-check").map(row), running: runningNow.items.map(row) };
|
|
2055
|
+
})(),
|
|
2056
|
+
};
|
|
2057
|
+
// Which tabs hold something new since this browser last looked (the dots on a phone).
|
|
2058
|
+
const tab = parseInboxTab(url.searchParams.get("tab")) ?? "all";
|
|
2059
|
+
const prints = inboxFingerprints(inboxData);
|
|
2060
|
+
const seenWords = /(?:^|;\s*)so-inbox-seen=([0-9a-f]{8}(?:\.[0-9a-f]{8}){3})(?:;|$)/.exec(request.headers.cookie ?? "")?.[1]?.split(".") ?? null;
|
|
2061
|
+
const order = ["needs-you", "ready", "running", "all"];
|
|
2062
|
+
const seen = Object.fromEntries(order.map((one, index) => [one, seenWords?.[index] ?? ""]));
|
|
2063
|
+
const unread = order.filter(one => seenWords !== null && seen[one] !== prints[one]);
|
|
2064
|
+
for (const one of order)
|
|
2065
|
+
if (tab === "all" || one === tab)
|
|
2066
|
+
seen[one] = prints[one];
|
|
2067
|
+
response.setHeader("Set-Cookie", `so-inbox-seen=${order.map(one => seen[one] || prints[one]).join(".")}; SameSite=Lax; Path=/; Max-Age=31536000`);
|
|
2068
|
+
return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), { ...inboxData, tab, unread }));
|
|
1926
2069
|
}
|
|
1927
2070
|
if (url.pathname === "/work") {
|
|
1928
2071
|
// The Work destination (workspace package 1): every task in view as
|
|
@@ -2597,7 +2740,7 @@ export function createDecisionServer(options) {
|
|
|
2597
2740
|
}
|
|
2598
2741
|
: null;
|
|
2599
2742
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
2600
|
-
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));
|
|
2743
|
+
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()));
|
|
2601
2744
|
}
|
|
2602
2745
|
if (url.pathname === "/queue") {
|
|
2603
2746
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
@@ -2941,10 +3084,31 @@ export function createDecisionServer(options) {
|
|
|
2941
3084
|
}
|
|
2942
3085
|
return sendScreen(response, 200, capsPage(chromeFor(project, "caps"), store.listCapabilities(project), computeGaps(store, project, now), project, now));
|
|
2943
3086
|
}
|
|
3087
|
+
// Flows → New: the gallery, and each template's page.
|
|
3088
|
+
if (url.pathname === "/flows/new") {
|
|
3089
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
3090
|
+
const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
|
|
3091
|
+
const repo = url.searchParams.get("repo");
|
|
3092
|
+
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") }));
|
|
3093
|
+
}
|
|
3094
|
+
const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
|
|
3095
|
+
if (galleryRead !== null) {
|
|
3096
|
+
const template = galleryTemplateOf(galleryRead[1]);
|
|
3097
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
3098
|
+
if (template === null)
|
|
3099
|
+
return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
|
|
3100
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
3101
|
+
return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
|
|
3102
|
+
if (projects.length === 0)
|
|
3103
|
+
return refuse(response, who, 409, "Add a project first.", "/projects");
|
|
3104
|
+
const asked = url.searchParams.get("repo");
|
|
3105
|
+
const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : projects[0];
|
|
3106
|
+
return sendGalleryUse(response, who, template, projects, repo, null, template.name, null);
|
|
3107
|
+
}
|
|
2944
3108
|
if (url.pathname === "/flows") {
|
|
2945
3109
|
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
2946
3110
|
const flows = store.listFlows(projects);
|
|
2947
|
-
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") }));
|
|
3111
|
+
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 } }));
|
|
2948
3112
|
}
|
|
2949
3113
|
// starter kits — the gallery, and each kit's checklist in a project.
|
|
2950
3114
|
if (url.pathname === "/kits" || /^\/kits\/[a-z-]{1,40}$/.test(url.pathname)) {
|
|
@@ -2990,6 +3154,22 @@ export function createDecisionServer(options) {
|
|
|
2990
3154
|
return json(404, { said: "No such run." });
|
|
2991
3155
|
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 ?? "" });
|
|
2992
3156
|
}
|
|
3157
|
+
const flowExport = /^\/flows\/([1-9][0-9]{0,9})\/export$/.exec(url.pathname);
|
|
3158
|
+
if (flowExport !== null) {
|
|
3159
|
+
// A flow as a file (flow-share.ts): zones, paths, trigger settings and scripts; never secrets, addresses, names or cards.
|
|
3160
|
+
const flow = store.getFlow(Number(flowExport[1]));
|
|
3161
|
+
if (flow === null || flow.state !== "active" || !visible(flow.repo))
|
|
3162
|
+
return refuse(response, who, 404, "No such flow in your projects.", "/flows");
|
|
3163
|
+
let exported;
|
|
3164
|
+
try {
|
|
3165
|
+
exported = exportFlow(store, flow, options.configDir ?? null);
|
|
3166
|
+
}
|
|
3167
|
+
catch (error) {
|
|
3168
|
+
return refuse(response, who, 409, error instanceof Error ? error.message : "This flow can't be exported.", `/flows/${flow.id}`);
|
|
3169
|
+
}
|
|
3170
|
+
response.setHeader("Content-Disposition", `attachment; filename="${exported.fileName}"`);
|
|
3171
|
+
return respond(response, 200, "application/json; charset=utf-8", exported.json);
|
|
3172
|
+
}
|
|
2993
3173
|
const flowLive = /^\/flows\/([1-9][0-9]{0,9})\/live$/.exec(url.pathname);
|
|
2994
3174
|
if (flowLive !== null) {
|
|
2995
3175
|
// The live canvas (v88): a nudge the moment the flow changes, and who
|
|
@@ -3426,7 +3606,11 @@ export function createDecisionServer(options) {
|
|
|
3426
3606
|
}
|
|
3427
3607
|
// The first run belongs to the lead conversation, for someone who can act on it.
|
|
3428
3608
|
const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
|
|
3429
|
-
const
|
|
3609
|
+
const phone = focusTask === null && who.via === "cookie" ? phoneCard(who, now) : undefined;
|
|
3610
|
+
// Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
|
|
3611
|
+
const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
|
|
3612
|
+
const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
|
|
3613
|
+
: { ...shown, workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
|
|
3430
3614
|
if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
|
|
3431
3615
|
{
|
|
3432
3616
|
const said = takeMateNote(who.session.csrf, mateSession.id);
|
|
@@ -3453,6 +3637,7 @@ export function createDecisionServer(options) {
|
|
|
3453
3637
|
}
|
|
3454
3638
|
return sendScreen(response, 200, withFirstRun(chatPage(chromeFor(null, "chat", undefined, "all"), {
|
|
3455
3639
|
settingsOpen: settingsView,
|
|
3640
|
+
firstRunShown: firstRun !== undefined,
|
|
3456
3641
|
enabled,
|
|
3457
3642
|
pending,
|
|
3458
3643
|
latched,
|
|
@@ -3675,7 +3860,9 @@ export function createDecisionServer(options) {
|
|
|
3675
3860
|
repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
3676
3861
|
canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
|
|
3677
3862
|
said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
|
|
3678
|
-
})
|
|
3863
|
+
}) +
|
|
3864
|
+
// Every template, beside the starters: Use this opens its page for this project.
|
|
3865
|
+
`<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo() })}`;
|
|
3679
3866
|
return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
3680
3867
|
}
|
|
3681
3868
|
// Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
|
|
@@ -3737,6 +3924,18 @@ export function createDecisionServer(options) {
|
|
|
3737
3924
|
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
3738
3925
|
return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
3739
3926
|
}
|
|
3927
|
+
// Settings → Projects → Checks: how much checks after each build, and the quick command beside the full one.
|
|
3928
|
+
if (url.pathname === "/settings/checks") {
|
|
3929
|
+
const repo = url.searchParams.get("repo") ?? "";
|
|
3930
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
3931
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
3932
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
3933
|
+
const quick = liveQuickCommand(store, repo);
|
|
3934
|
+
const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
|
|
3935
|
+
level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
|
|
3936
|
+
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
3937
|
+
return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
3938
|
+
}
|
|
3740
3939
|
// Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
|
|
3741
3940
|
if (url.pathname === "/settings/backups") {
|
|
3742
3941
|
const databaseFile = store.databaseFile();
|
|
@@ -3830,6 +4029,27 @@ export function createDecisionServer(options) {
|
|
|
3830
4029
|
}
|
|
3831
4030
|
return sendScreen(response, 200, screen("Learning", `<p><a href="/settings">Settings</a></p><h1>Learning</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
|
|
3832
4031
|
}
|
|
4032
|
+
// Settings → Lead (onboarding): what runs the lead in one line, and the full form under Advanced.
|
|
4033
|
+
if (url.pathname === "/settings/lead") {
|
|
4034
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
4035
|
+
return refuse(response, who, 403, "An approver sets up the lead.", "/settings");
|
|
4036
|
+
if (localSignIn === null)
|
|
4037
|
+
await Promise.race([checkLocalAgents(), new Promise(done => setTimeout(done, 6_000).unref?.())]);
|
|
4038
|
+
const config = store.getChatConfig();
|
|
4039
|
+
const facts = {
|
|
4040
|
+
keyFacts: ["anthropic-api", "openrouter-api"].map(one => {
|
|
4041
|
+
const found = chatKeyFor(one);
|
|
4042
|
+
return { provider: one, state: found === null ? "none" : found.source, tail: found === null || found.source === "environment" ? null : redactToken(found.key) };
|
|
4043
|
+
}),
|
|
4044
|
+
openrouterModels: (await chatCatalog())?.map(one => one.id) ?? null,
|
|
4045
|
+
liveModels: [...modelOptions(store, "claude", now, providerHome), ...modelOptions(store, "codex", now, providerHome)],
|
|
4046
|
+
csrf: who.session.csrf,
|
|
4047
|
+
returnTo: "/settings/lead",
|
|
4048
|
+
};
|
|
4049
|
+
const signedIn = localSignIn?.states.claude === "connected" ? "Claude Code" : localSignIn?.states.codex === "connected" ? "Codex" : null;
|
|
4050
|
+
return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
|
|
4051
|
+
said: url.searchParams.get("said") }), { chrome: chromeFor(project, "settings") }));
|
|
4052
|
+
}
|
|
3833
4053
|
if (url.pathname === "/settings/telegram") {
|
|
3834
4054
|
// Any approver pairs their OWN phone here; the bot token stays on /settings.
|
|
3835
4055
|
if (who.via !== "cookie" || who.role !== "approver")
|
|
@@ -3913,7 +4133,7 @@ export function createDecisionServer(options) {
|
|
|
3913
4133
|
return at === null || since === null ? null : firstResultWords(since, at);
|
|
3914
4134
|
})(), (() => {
|
|
3915
4135
|
const chosen = store.notificationPreference(who.name);
|
|
3916
|
-
return { mode: chosen.mode, digestAt: chosen.digestAt };
|
|
4136
|
+
return { mode: chosen.mode, digestAt: chosen.digestAt, projects: notificationProjects(store, who.name) };
|
|
3917
4137
|
})()));
|
|
3918
4138
|
}
|
|
3919
4139
|
if (url.pathname === "/push/key") {
|
|
@@ -3938,6 +4158,18 @@ export function createDecisionServer(options) {
|
|
|
3938
4158
|
const back = decisionReturn === null ? null : safeChatReturn(decisionReturn);
|
|
3939
4159
|
return sendScreen(response, 200, decisionPage(chromeFor(project, "none"), decision, taskId, store.evidenceFor(decision.id), who, now, back));
|
|
3940
4160
|
}
|
|
4161
|
+
// While no agent is signed in, Chat asks this computer again on its own (onboarding): a fresh, non-spending sign-in
|
|
4162
|
+
// check at most every few seconds, and the lead turns on as soon as one is found.
|
|
4163
|
+
if (url.pathname === "/lead/status") {
|
|
4164
|
+
if (who.via !== "cookie")
|
|
4165
|
+
return respond(response, 403, "application/json", JSON.stringify({ error: "sign in" }));
|
|
4166
|
+
if (Date.now() - leadRecheckedAt > 4_000) {
|
|
4167
|
+
leadRecheckedAt = Date.now();
|
|
4168
|
+
await Promise.race([checkLocalAgents(true), new Promise(done => setTimeout(done, 8_000).unref?.())]);
|
|
4169
|
+
}
|
|
4170
|
+
response.setHeader("cache-control", "no-store");
|
|
4171
|
+
return respond(response, 200, "application/json", JSON.stringify({ lead: store.getChatConfig() === null ? "off" : "on", agent: localSignIn?.signedIn ?? null, command: agentSignInCommand() }));
|
|
4172
|
+
}
|
|
3941
4173
|
if (url.pathname === "/chat/task-status") {
|
|
3942
4174
|
if (who.via !== "cookie")
|
|
3943
4175
|
return respond(response, 403, "text/plain; charset=utf-8", "sign in to see this task");
|
|
@@ -3963,7 +4195,7 @@ export function createDecisionServer(options) {
|
|
|
3963
4195
|
function firstRunStepsNow(now) {
|
|
3964
4196
|
if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
|
|
3965
4197
|
return null;
|
|
3966
|
-
return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
|
|
4198
|
+
return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null, signInCommand: agentSignInCommand() });
|
|
3967
4199
|
}
|
|
3968
4200
|
/**
|
|
3969
4201
|
* Whether a coding agent is signed in where the work runs. This machine's
|
|
@@ -3983,17 +4215,54 @@ export function createDecisionServer(options) {
|
|
|
3983
4215
|
return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
|
|
3984
4216
|
}
|
|
3985
4217
|
let localSignIn = null;
|
|
3986
|
-
let localSignInChecking =
|
|
4218
|
+
let localSignInChecking = null;
|
|
4219
|
+
let leadRecheckedAt = 0;
|
|
4220
|
+
/** Ask this computer's agent CLIs again (no model, nothing spent); `fresh` skips their 30-second cache. */
|
|
4221
|
+
function checkLocalAgents(fresh = false) {
|
|
4222
|
+
if (localSignInChecking !== null)
|
|
4223
|
+
return localSignInChecking;
|
|
4224
|
+
localSignInChecking = Promise.all(["claude", "codex"].map(one => connectionCheck(one, fresh).then(value => value.state, () => "unverified")))
|
|
4225
|
+
.then(([claude, codex]) => {
|
|
4226
|
+
localSignIn = { at: Date.now(), signedIn: [claude, codex].some(state => state === "connected" || state === "key-works" || state === "key-present"), states: { claude: claude, codex: codex } };
|
|
4227
|
+
leadByDefault();
|
|
4228
|
+
})
|
|
4229
|
+
.catch(() => { })
|
|
4230
|
+
.finally(() => { localSignInChecking = null; });
|
|
4231
|
+
return localSignInChecking;
|
|
4232
|
+
}
|
|
3987
4233
|
function localAgentSignedIn() {
|
|
3988
|
-
if (
|
|
3989
|
-
|
|
3990
|
-
void Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")))
|
|
3991
|
-
.then(states => { localSignIn = { at: Date.now(), signedIn: states.some(state => state === "connected" || state === "key-works" || state === "key-present") }; })
|
|
3992
|
-
.catch(() => { })
|
|
3993
|
-
.finally(() => { localSignInChecking = false; });
|
|
3994
|
-
}
|
|
4234
|
+
if (localSignInChecking === null && (localSignIn === null || Date.now() - localSignIn.at > 30_000))
|
|
4235
|
+
void checkLocalAgents();
|
|
3995
4236
|
return localSignIn?.signedIn ?? null;
|
|
3996
4237
|
}
|
|
4238
|
+
/**
|
|
4239
|
+
* The lead on by default (onboarding): with no lead set up yet, the agent CLI signed in on this computer runs it
|
|
4240
|
+
* (Claude Code first, else Codex) on its membership with today's safe defaults: the CLI's own default model, 50 turns
|
|
4241
|
+
* a day, no dollar spend, and every action it proposes still a card a person confirms. Once only, ever: a lead
|
|
4242
|
+
* someone turned off stays off, and Settings → Lead changes it.
|
|
4243
|
+
*/
|
|
4244
|
+
function leadByDefault() {
|
|
4245
|
+
if (options.leadByDefault !== true || store.isDemo() || store.getChatConfig() !== null || store.installationFact(LEAD_BY_DEFAULT_FACT) !== null)
|
|
4246
|
+
return;
|
|
4247
|
+
const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
|
|
4248
|
+
if (provider === null)
|
|
4249
|
+
return;
|
|
4250
|
+
const now = clock();
|
|
4251
|
+
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, "toolroll", now);
|
|
4252
|
+
store.recordInstallationFact(LEAD_BY_DEFAULT_FACT, provider, now);
|
|
4253
|
+
}
|
|
4254
|
+
// Look once at start, so the lead is on by the time the browser opens.
|
|
4255
|
+
if (options.leadByDefault === true && !store.isDemo() && store.getChatConfig() === null)
|
|
4256
|
+
void checkLocalAgents();
|
|
4257
|
+
/** What the lead runs on, in words for one line ("your Claude Code sign-in"); null for a direct API or no lead. */
|
|
4258
|
+
function leadWords() {
|
|
4259
|
+
const config = store.getChatConfig();
|
|
4260
|
+
return config?.provider === "claude-subscription" ? "your Claude Code sign-in" : config?.provider === "codex-subscription" ? "your Codex sign-in" : null;
|
|
4261
|
+
}
|
|
4262
|
+
/** 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. */
|
|
4263
|
+
function agentSignInCommand() {
|
|
4264
|
+
return signInCommandFor(localSignIn?.states ?? null, options.platform ?? process.platform);
|
|
4265
|
+
}
|
|
3997
4266
|
/** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
|
|
3998
4267
|
* every ten minutes. A render never waits on `gh` or `git grep`. */
|
|
3999
4268
|
const firstTasks = new Map();
|
|
@@ -4017,10 +4286,43 @@ export function createDecisionServer(options) {
|
|
|
4017
4286
|
return undefined;
|
|
4018
4287
|
const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
|
|
4019
4288
|
const repo = project ?? managedRepos()[0] ?? null;
|
|
4289
|
+
const agent = steps.find(one => one.key === "agent");
|
|
4290
|
+
const lead = leadWords();
|
|
4020
4291
|
return {
|
|
4021
4292
|
steps,
|
|
4022
4293
|
suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
|
|
4023
|
-
sandbox:
|
|
4294
|
+
sandbox: agent !== undefined && !agent.done && !agent.checking ? SANDBOX_COMMAND : null,
|
|
4295
|
+
intro: HOW_IT_WORKS,
|
|
4296
|
+
lead: lead === null ? null : { words: `The lead uses ${lead}`, href: "/settings/lead" },
|
|
4297
|
+
// With no lead yet and no agent signed in, the page asks this computer again on its own.
|
|
4298
|
+
recheck: store.getChatConfig() === null && agent !== undefined && !agent.done ? "/lead/status" : null,
|
|
4299
|
+
};
|
|
4300
|
+
}
|
|
4301
|
+
/** The first task's way to Ready shows until the first Ready result, and stays on the installation's first task. */
|
|
4302
|
+
function guided(rootId, now) {
|
|
4303
|
+
if (store.isDemo())
|
|
4304
|
+
return false;
|
|
4305
|
+
if (store.firstSuccessAt(now) === null)
|
|
4306
|
+
return true;
|
|
4307
|
+
const first = store.handle.prepare("SELECT id FROM task ORDER BY created_at, rowid LIMIT 1").get();
|
|
4308
|
+
return first !== undefined && String(first["id"]) === rootId;
|
|
4309
|
+
}
|
|
4310
|
+
/** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
|
|
4311
|
+
function phoneCard(who, now) {
|
|
4312
|
+
if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
|
|
4313
|
+
return undefined;
|
|
4314
|
+
const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
|
|
4315
|
+
if (botId !== null && store.liveTelegramBindings(botId).length > 0)
|
|
4316
|
+
return undefined;
|
|
4317
|
+
const port = servedPort() ?? 4180;
|
|
4318
|
+
const address = server.address();
|
|
4319
|
+
const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
|
|
4320
|
+
const name = tailnet[0] ?? null;
|
|
4321
|
+
const reachable = bound !== "127.0.0.1" && bound !== "::1";
|
|
4322
|
+
return {
|
|
4323
|
+
chatApps: [{ label: "Telegram", href: "/settings/telegram" }, { label: "Slack", href: "/settings/slack" }, { label: "Discord", href: "/settings/discord" }, { label: "Teams", href: "/settings/teams" }],
|
|
4324
|
+
tailnet: name === null ? null : { address: `http://${name}:${port}/`, restart: reachable ? null : consoleCommand({ host: "0.0.0.0" }) },
|
|
4325
|
+
dismissHref: "/onboarding/phone/dismiss",
|
|
4024
4326
|
};
|
|
4025
4327
|
}
|
|
4026
4328
|
// ---- fleet chat (v13) ----------------------------------------------------
|
|
@@ -4218,7 +4520,7 @@ export function createDecisionServer(options) {
|
|
|
4218
4520
|
return { ok: false, code: "empty", why: "add a project first — chat will include it automatically" };
|
|
4219
4521
|
const config = store.getChatConfig();
|
|
4220
4522
|
if (config === null)
|
|
4221
|
-
return { ok: false, code: "unconfigured", why: "
|
|
4523
|
+
return { ok: false, code: "unconfigured", why: "the lead is off — turn it on in Settings → Lead, or from the terminal: toolroll config set chat" };
|
|
4222
4524
|
if (isSubscriptionChatProvider(config.provider)) {
|
|
4223
4525
|
return {
|
|
4224
4526
|
ok: true,
|
|
@@ -4642,6 +4944,8 @@ export function createDecisionServer(options) {
|
|
|
4642
4944
|
...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
|
|
4643
4945
|
...(s.chrome.update === undefined ? {} : { update: s.chrome.update }),
|
|
4644
4946
|
...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
|
|
4947
|
+
...(extras.phone === undefined ? {} : { phone: extras.phone }),
|
|
4948
|
+
...(extras.home == null ? {} : { home: extras.home }),
|
|
4645
4949
|
};
|
|
4646
4950
|
if (requestFacts.workspaceRead) {
|
|
4647
4951
|
const validator = requestFacts.workspaceValidator;
|
|
@@ -4695,6 +4999,38 @@ export function createDecisionServer(options) {
|
|
|
4695
4999
|
* package 1): Work's own Needs-you membership, cached per viewer and
|
|
4696
5000
|
* project, so the rail, the phone tab, the scope bar, the project cards,
|
|
4697
5001
|
* the chat overview, and the Needs-you tab never disagree. */
|
|
5002
|
+
/** The Chat landing's live view: each agent at work now with its task and
|
|
5003
|
+
* phase, four counts, plan-window use (never dollars: subscriptions don't
|
|
5004
|
+
* bill per run) and Catch up's items, each tagged with its tab. Reads only. */
|
|
5005
|
+
function chatHomeOf(who, repos, now) {
|
|
5006
|
+
const access = { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) };
|
|
5007
|
+
const admitted = (repo) => repo === null ? visible(null) : repos.includes(repo);
|
|
5008
|
+
const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
|
|
5009
|
+
const root = familyOf(run.taskId)?.root ?? null;
|
|
5010
|
+
return { runId: run.id, taskId: root?.id ?? run.taskId, title: root?.title ?? run.title, href: taskHref(root?.id ?? run.taskId),
|
|
5011
|
+
agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt };
|
|
5012
|
+
});
|
|
5013
|
+
const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
|
|
5014
|
+
const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
|
|
5015
|
+
const done = workIndexPage(store, now, access, { view: "completed", limit: WORK_INDEX_MAX_LIMIT });
|
|
5016
|
+
const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
|
|
5017
|
+
const counts = [
|
|
5018
|
+
{ key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
|
|
5019
|
+
{ key: "waiting", label: "Waiting on you", value: needs.items.filter(one => one.assignmentState === "needs-decision").length, href: "/work?view=needs-you" },
|
|
5020
|
+
{ key: "ready", label: "Ready to review", value: needs.items.filter(one => one.assignmentState === "ready-to-check").length, href: "/work?view=needs-you" },
|
|
5021
|
+
{ key: "done", label: "Done this week", value: done.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo).length, href: "/work?view=completed" },
|
|
5022
|
+
];
|
|
5023
|
+
const tabOf = (state) => state === "needs-decision" ? "needs-you" : state === "ready-to-check" ? "ready" : state === "working" || state === "checking" ? "running" : "finished";
|
|
5024
|
+
const catchUp = all.items.filter(one => tabOf(one.assignmentState) !== "finished" || one.updatedAt >= weekAgo).slice(0, 40).map(one => ({
|
|
5025
|
+
id: one.rootId, title: one.title, href: taskHref(one.rootId), project: one.repo === null ? null : projectName(one.repo), tab: tabOf(one.assignmentState),
|
|
5026
|
+
label: one.status.label, tone: one.status.tone, detail: one.status.detail, at: one.updatedAt,
|
|
5027
|
+
}));
|
|
5028
|
+
const windows = who.via === "cookie" && store.isInstanceOperator(who.name) ? limitsView(store.providerLimits(), [], { project: projectName, teammate: id => `Teammate ${id}` }, now) : null;
|
|
5029
|
+
return {
|
|
5030
|
+
agents, counts, catchUp, allHref: "/work",
|
|
5031
|
+
planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
|
|
5032
|
+
};
|
|
5033
|
+
}
|
|
4698
5034
|
function needsYouBadge(project) {
|
|
4699
5035
|
const actor = requestContext.getStore()?.actor;
|
|
4700
5036
|
const key = `${actor ?? ""}:${actor === undefined ? "" : store.accountOf(actor)?.generation}:${project ?? ""}`;
|
|
@@ -4891,6 +5227,7 @@ export function createDecisionServer(options) {
|
|
|
4891
5227
|
verdict: verdict?.verdict ?? null,
|
|
4892
5228
|
reasons: verdict?.reasons ?? [],
|
|
4893
5229
|
accepted: store.proofAcceptance(latest.id) !== null,
|
|
5230
|
+
checkLevel: resultCheckLevel(store, latest.id, now),
|
|
4894
5231
|
review: reviewFactsFor(latest.id),
|
|
4895
5232
|
...(latest.outcome !== "no-change"
|
|
4896
5233
|
? {}
|
|
@@ -4907,8 +5244,6 @@ export function createDecisionServer(options) {
|
|
|
4907
5244
|
const result = receipt === undefined && task.state === "done" && latest !== null && (latest.outcome === "built" || latest.outcome === "no-change")
|
|
4908
5245
|
? completionReceiptView(store, latest, store.artifactsFor(latest.id), evidenceRoot, reviewFactsFor(latest.id)) : receipt;
|
|
4909
5246
|
const status = workStatusOf(facts, result == null ? undefined : receiptStatusOf(result));
|
|
4910
|
-
if (store.lookupRef(task.id)?.revisionOf !== null && live !== null && status.tone === "live")
|
|
4911
|
-
status.label = "Revising";
|
|
4912
5247
|
const earlierLive = task.family === undefined ? [] : earlierLiveVersions(task.family, now);
|
|
4913
5248
|
if (earlierLive.length > 0 && !status.views.includes("running"))
|
|
4914
5249
|
status.views = [...status.views, "running"];
|
|
@@ -5020,7 +5355,7 @@ export function createDecisionServer(options) {
|
|
|
5020
5355
|
peeks[one.path] = peekOf(one.path);
|
|
5021
5356
|
for (const path of candidates)
|
|
5022
5357
|
peeks[path] = peekOf(path);
|
|
5023
|
-
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));
|
|
5358
|
+
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));
|
|
5024
5359
|
}
|
|
5025
5360
|
/** The task screen, shared by the GET and by every refusal that re-renders it. */
|
|
5026
5361
|
/**
|
|
@@ -5143,6 +5478,7 @@ export function createDecisionServer(options) {
|
|
|
5143
5478
|
const assignment = freshAssignment(recordedAssignment, completion?.receipt ?? null);
|
|
5144
5479
|
const progressRun = runs.find(one => runIsLive(one))?.id ?? completion?.runId ?? null;
|
|
5145
5480
|
return {
|
|
5481
|
+
guide: guided(family?.root.id ?? taskId, now),
|
|
5146
5482
|
assignment,
|
|
5147
5483
|
checkProgress: progressRun === null ? null : store.checkProgress(progressRun),
|
|
5148
5484
|
task: found,
|
|
@@ -5434,6 +5770,7 @@ export function createDecisionServer(options) {
|
|
|
5434
5770
|
id: family.root.id,
|
|
5435
5771
|
executionId: task.id,
|
|
5436
5772
|
family,
|
|
5773
|
+
guide: guided(family.root.id, now),
|
|
5437
5774
|
history: familyHistory(family),
|
|
5438
5775
|
title: family.root.title,
|
|
5439
5776
|
state: task.state,
|
|
@@ -5495,8 +5832,12 @@ export function createDecisionServer(options) {
|
|
|
5495
5832
|
const publishing = publishingOf(store, repo);
|
|
5496
5833
|
const target = publishing.on ? publishing.base : null;
|
|
5497
5834
|
const view = newestPullRequestOf(store, family === null ? [taskId] : family.versions.map(one => one.id));
|
|
5498
|
-
if (view !== null)
|
|
5499
|
-
|
|
5835
|
+
if (view !== null) {
|
|
5836
|
+
const publication = store.publicationForRun(view.runId);
|
|
5837
|
+
const fact = publication === null ? null : { ...pullRequestFactOf({ ...publication, merged: view.state === "merged" }),
|
|
5838
|
+
note: view.state === "merged" ? `${view.detail}${view.mergeCommit === null ? "" : ` Merge commit ${view.mergeCommit.slice(0, 12)}.`}` : null };
|
|
5839
|
+
return { taskId: rootId, view: { ...view, canMerge: view.canMerge && who.role === "approver" && who.via === "cookie" }, offer: null, target, fact };
|
|
5840
|
+
}
|
|
5500
5841
|
if (!publishing.on || who.via !== "cookie" || who.role !== "approver")
|
|
5501
5842
|
return null;
|
|
5502
5843
|
const assignment = assignmentOf(store, taskId, clock(), { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) }, evidenceRoot);
|
|
@@ -5521,6 +5862,12 @@ export function createDecisionServer(options) {
|
|
|
5521
5862
|
if (cancelDraft !== undefined)
|
|
5522
5863
|
presentedData.cancelDraft = cancelDraft;
|
|
5523
5864
|
presentedData.pullRequest = taskPullRequestOf(taskId, who);
|
|
5865
|
+
// The thread reads the whole family: the original, its revisions, and every attempt across them.
|
|
5866
|
+
if (family !== null && family.problem === null) {
|
|
5867
|
+
presentedData.family = { root: { id: family.root.id, title: family.root.title, createdAt: family.root.createdAt, goal: store.getScope(family.root.id)?.goal ?? null },
|
|
5868
|
+
versions: family.versions.map(one => ({ id: one.id, title: one.title, state: one.state })),
|
|
5869
|
+
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 }))) };
|
|
5870
|
+
}
|
|
5524
5871
|
const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
|
|
5525
5872
|
const page = taskPage(paneProject === null && !unscopedMode
|
|
5526
5873
|
? chromeFor(paneProject, "tasks")
|
|
@@ -5528,10 +5875,13 @@ export function createDecisionServer(options) {
|
|
|
5528
5875
|
// The task's own conversation, docked beside the page (v77).
|
|
5529
5876
|
const focus = taskChatFocus(family?.root.id ?? taskId, clock(), who, { mintNonce: false });
|
|
5530
5877
|
const docked = focus === null ? null : dockedConversation(who, focus, null, clock(), taskHref(focus.id));
|
|
5531
|
-
// The
|
|
5532
|
-
|
|
5878
|
+
// The conversation is the page's own thread here, so the page drops its Ask tab;
|
|
5879
|
+
// only an approver is offered a road to message the agent.
|
|
5880
|
+
const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }) } : page.workspace?.view;
|
|
5533
5881
|
if (docked !== null)
|
|
5534
5882
|
page.workspace = { ...page.workspace, ...(taskView === undefined ? {} : { view: taskView }), conversation: docked, pageHtml: page.body };
|
|
5883
|
+
else if (taskView !== undefined && page.workspace !== undefined)
|
|
5884
|
+
page.workspace = { ...page.workspace, view: taskView };
|
|
5535
5885
|
return sendScreen(response, status, page);
|
|
5536
5886
|
}
|
|
5537
5887
|
/** The routine screen: the standing order restated, its verbs, its ledger. */
|
|
@@ -6354,14 +6704,45 @@ export function createDecisionServer(options) {
|
|
|
6354
6704
|
return redirect(response, `/teammates?said=${encodeURIComponent(done.said)}`);
|
|
6355
6705
|
return redirect(response, `${back}?${done.ok ? "said" : "problem"}=${encodeURIComponent(done.said)}${action === "note" ? "#memory" : ""}`);
|
|
6356
6706
|
}
|
|
6707
|
+
// Flows → New → a template: preview these answers, or create exactly what was previewed.
|
|
6708
|
+
const galleryPost = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
|
|
6709
|
+
if (galleryPost !== null) {
|
|
6710
|
+
const template = galleryTemplateOf(galleryPost[1]);
|
|
6711
|
+
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
6712
|
+
if (template === null)
|
|
6713
|
+
return refuse(response, who, 404, "There's no template by that name.", "/flows/new");
|
|
6714
|
+
if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
|
|
6715
|
+
return refuse(response, who, 403, "Sign in as an approver to create flows.", "/flows/new");
|
|
6716
|
+
const repo = body.get("repo") ?? "";
|
|
6717
|
+
if (!projects.includes(repo))
|
|
6718
|
+
return refuse(response, who, 404, "Choose one of your projects.", "/flows/new");
|
|
6719
|
+
const answers = Object.fromEntries(template.asks.map(ask => [ask.key, body.get(ask.key) ?? ""]));
|
|
6720
|
+
const name = (body.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || template.name;
|
|
6721
|
+
if (body.get("intent") !== "create")
|
|
6722
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, null);
|
|
6723
|
+
// What's made is what was previewed: changed answers are previewed again first.
|
|
6724
|
+
let digest = null;
|
|
6725
|
+
try {
|
|
6726
|
+
digest = previewGallery(store, template, repo, answers, who.name, clock()).digest;
|
|
6727
|
+
}
|
|
6728
|
+
catch {
|
|
6729
|
+
digest = null;
|
|
6730
|
+
}
|
|
6731
|
+
if (digest === null || digest !== body.get("previewed"))
|
|
6732
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, digest === null ? null : "Your answers changed. Check the preview, then create the flow.");
|
|
6733
|
+
const used = useGalleryTemplate(store, template, repo, answers, { name, by: who.name, now: clock(), dir: options.configDir ?? null });
|
|
6734
|
+
if (!used.ok)
|
|
6735
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, used.said);
|
|
6736
|
+
return redirect(response, `/flows/${used.flow}`);
|
|
6737
|
+
}
|
|
6357
6738
|
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);
|
|
6358
6739
|
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);
|
|
6359
|
-
if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || flowPost !== null || triggerPost !== null) {
|
|
6740
|
+
if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
|
|
6360
6741
|
const now = clock();
|
|
6361
6742
|
const answer = (status, payload) => respond(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
|
|
6362
6743
|
const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
6363
6744
|
if (who.via !== "cookie" || who.role !== "approver")
|
|
6364
|
-
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." });
|
|
6745
|
+
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." });
|
|
6365
6746
|
if (url.pathname === "/flows/example") {
|
|
6366
6747
|
// A first look (v88): the Email replies template with one sample question, which Claude drafts a reply to straight away.
|
|
6367
6748
|
const repo = body.get("repo") ?? "";
|
|
@@ -6372,6 +6753,52 @@ export function createDecisionServer(options) {
|
|
|
6372
6753
|
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);
|
|
6373
6754
|
return redirect(response, `/flows/${id}`);
|
|
6374
6755
|
}
|
|
6756
|
+
if (url.pathname === "/flows/import") {
|
|
6757
|
+
// A flow file, chosen or fetched from a gist: previewed in plain words, then made with its triggers off and its scripts held.
|
|
6758
|
+
const repo = body.get("repo") ?? "";
|
|
6759
|
+
const back = (problem) => redirect(response, `/flows?problem=${encodeURIComponent(problem)}`);
|
|
6760
|
+
if (!projects.includes(repo))
|
|
6761
|
+
return back("Choose one of your projects.");
|
|
6762
|
+
let document = body.get("document") ?? "";
|
|
6763
|
+
const address = (body.get("url") ?? "").trim();
|
|
6764
|
+
let file;
|
|
6765
|
+
try {
|
|
6766
|
+
if (document.trim() === "" && address !== "")
|
|
6767
|
+
document = await fetchFlowFile(address, options.flowFetch);
|
|
6768
|
+
if (document.trim() === "")
|
|
6769
|
+
return back("Choose a flow file or give its address.");
|
|
6770
|
+
file = parseFlowFile(document);
|
|
6771
|
+
}
|
|
6772
|
+
catch (error) {
|
|
6773
|
+
if (error instanceof FlowFileError)
|
|
6774
|
+
return back(error.message);
|
|
6775
|
+
throw error;
|
|
6776
|
+
}
|
|
6777
|
+
const given = Object.fromEntries(file.parameters.flatMap(one => body.has(`param.${one.id}`) ? [[one.id, body.get(`param.${one.id}`) ?? ""]] : []));
|
|
6778
|
+
// What was previewed: the file and every value it resolved to (a default the first preview filled in included).
|
|
6779
|
+
const previewedOf = (plan) => createHash("sha256").update(`${document}\0${JSON.stringify(plan?.values ?? given)}`).digest("hex").slice(0, 32);
|
|
6780
|
+
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") }));
|
|
6781
|
+
let plan;
|
|
6782
|
+
try {
|
|
6783
|
+
plan = planFlowImport(store, repo, file, given, who.name);
|
|
6784
|
+
}
|
|
6785
|
+
catch (error) {
|
|
6786
|
+
if (error instanceof FlowFileError)
|
|
6787
|
+
return page(null, error.message, 400);
|
|
6788
|
+
throw error;
|
|
6789
|
+
}
|
|
6790
|
+
if (body.get("confirm") === "yes") {
|
|
6791
|
+
if (body.get("previewed") !== previewedOf(plan))
|
|
6792
|
+
return page(plan, "What you filled in changed. Check the preview again, then import.", 409);
|
|
6793
|
+
try {
|
|
6794
|
+
return redirect(response, `/flows/${importFlow(store, plan, who.name, now, options.configDir ?? null).id}`);
|
|
6795
|
+
}
|
|
6796
|
+
catch (error) {
|
|
6797
|
+
return page(plan, error instanceof Error ? error.message : "That flow couldn't be imported.", 400);
|
|
6798
|
+
}
|
|
6799
|
+
}
|
|
6800
|
+
return page(plan, null, 200);
|
|
6801
|
+
}
|
|
6375
6802
|
if (url.pathname === "/flows/new") {
|
|
6376
6803
|
const repo = body.get("repo") ?? "";
|
|
6377
6804
|
if (!projects.includes(repo))
|
|
@@ -6467,6 +6894,8 @@ export function createDecisionServer(options) {
|
|
|
6467
6894
|
}
|
|
6468
6895
|
if (action === "scripts") {
|
|
6469
6896
|
// The project's script library, reached from any of its flows.
|
|
6897
|
+
if (body.get("approve") === "yes")
|
|
6898
|
+
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." });
|
|
6470
6899
|
if (body.get("remove") === "yes")
|
|
6471
6900
|
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." });
|
|
6472
6901
|
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);
|
|
@@ -6591,6 +7020,26 @@ export function createDecisionServer(options) {
|
|
|
6591
7020
|
return back("said", `${label}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`);
|
|
6592
7021
|
}
|
|
6593
7022
|
// Delete a project, in two steps: its name, then what goes and the password. An instance operator; the ledger keeps who and what.
|
|
7023
|
+
// Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
|
|
7024
|
+
if (url.pathname === "/settings/project/concurrency") {
|
|
7025
|
+
const repo = body.get("repo") ?? "";
|
|
7026
|
+
const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
|
|
7027
|
+
if (!known.includes(repo))
|
|
7028
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
7029
|
+
if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
|
|
7030
|
+
return refuse(response, who, 403, "An approver for this project sets how many tasks build at once.", "/settings/project");
|
|
7031
|
+
const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7032
|
+
const n = parseProjectConcurrency(body.get("concurrency") ?? "");
|
|
7033
|
+
if (n === null)
|
|
7034
|
+
return back("problem", "Builds at once is a whole number from 1 to 64. Nothing changed.");
|
|
7035
|
+
const databaseFile = store.databaseFile();
|
|
7036
|
+
if (databaseFile === null)
|
|
7037
|
+
return back("problem", "This database keeps no settings files. Nothing changed.");
|
|
7038
|
+
const changed = saveProjectConcurrency(databaseFile, repo, n);
|
|
7039
|
+
store.recordProjectConcurrency(who.name, repo, changed.before, changed.after, now);
|
|
7040
|
+
store.bumpWake();
|
|
7041
|
+
return back("said", changed.before === changed.after ? "Nothing changed." : `Saved. Up to ${n} at once.`);
|
|
7042
|
+
}
|
|
6594
7043
|
if (url.pathname === "/settings/project/delete") {
|
|
6595
7044
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
|
|
6596
7045
|
return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
|
|
@@ -6892,6 +7341,48 @@ export function createDecisionServer(options) {
|
|
|
6892
7341
|
}
|
|
6893
7342
|
return back("problem", "That change isn't available.");
|
|
6894
7343
|
}
|
|
7344
|
+
// Checks for one project: the level, or the quick command (approved like `verify set --quick`). Each takes
|
|
7345
|
+
// the password and lands in the ledger.
|
|
7346
|
+
if (url.pathname === "/settings/checks") {
|
|
7347
|
+
const repo = body.get("repo") ?? "";
|
|
7348
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
7349
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
7350
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
7351
|
+
if (who.role !== "approver" || store.isDemo())
|
|
7352
|
+
return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
|
|
7353
|
+
const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
7354
|
+
if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
|
|
7355
|
+
return back("problem", "Enter your Toolroll password to change checks.");
|
|
7356
|
+
const act = body.get("act");
|
|
7357
|
+
if (act === "level") {
|
|
7358
|
+
const level = body.get("level");
|
|
7359
|
+
if (!isCheckLevel(level))
|
|
7360
|
+
return back("problem", "Choose Quick, Full or Off.");
|
|
7361
|
+
const changed = setProjectCheckLevel(store, repo, level, who.name, now);
|
|
7362
|
+
return back("said", changed.changed ? `Checks are ${CHECK_LEVEL_WORDS[level]}.` : "Saved.");
|
|
7363
|
+
}
|
|
7364
|
+
if (act === "quick") {
|
|
7365
|
+
const command = (body.get("command") ?? "").trim();
|
|
7366
|
+
const seconds = Number(body.get("timeout") ?? "180");
|
|
7367
|
+
if (command === "" || command.length > 2000 || hasDisguisedText(command))
|
|
7368
|
+
return back("problem", "Enter the quick command: one line of up to 2000 characters.");
|
|
7369
|
+
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)) {
|
|
7370
|
+
return back("problem", "That command seems to hold a credential. Use an environment variable instead.");
|
|
7371
|
+
}
|
|
7372
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 3600)
|
|
7373
|
+
return back("problem", "Choose a time limit from 1 to 3600 seconds.");
|
|
7374
|
+
store.setVerifyCommand({ repo: quickVerifyKey(repo), command, timeoutMs: seconds * 1000, approvedBy: who.name }, now);
|
|
7375
|
+
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) });
|
|
7376
|
+
return back("said", "Quick check approved.");
|
|
7377
|
+
}
|
|
7378
|
+
if (act === "quick-clear") {
|
|
7379
|
+
const cleared = store.clearVerifyCommand(quickVerifyKey(repo), who.name, now);
|
|
7380
|
+
if (cleared)
|
|
7381
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "quick check removed", outcome: "removed", source: "policy" });
|
|
7382
|
+
return back("said", cleared ? "Quick check removed. Quick builds run the full check." : "There was no quick check.");
|
|
7383
|
+
}
|
|
7384
|
+
return back("problem", "That change isn't available.");
|
|
7385
|
+
}
|
|
6895
7386
|
// Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
|
|
6896
7387
|
if (url.pathname === "/settings/backups/now") {
|
|
6897
7388
|
const databaseFile = store.databaseFile();
|
|
@@ -7540,6 +8031,16 @@ export function createDecisionServer(options) {
|
|
|
7540
8031
|
: after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
|
|
7541
8032
|
return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
|
|
7542
8033
|
}
|
|
8034
|
+
if (url.pathname === "/settings/notifications/mute") {
|
|
8035
|
+
// Each person's own pings: a muted project stays in Tasks and the evening digest.
|
|
8036
|
+
const repo = body.get("repo") ?? "";
|
|
8037
|
+
const project = notificationProjects(store, who.name).find(one => one.repo === repo);
|
|
8038
|
+
if (project === undefined)
|
|
8039
|
+
return refuse(response, who, 400, "that project isn't one you can see", "/settings");
|
|
8040
|
+
const muted = body.get("pings") !== "on";
|
|
8041
|
+
store.setProjectMuted(who.name, repo, muted, now);
|
|
8042
|
+
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`);
|
|
8043
|
+
}
|
|
7543
8044
|
if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
|
|
7544
8045
|
// The cadence is a closed list of minutes — never a free number from
|
|
7545
8046
|
// a form; "off" clears it. Any approver session may set it.
|
|
@@ -7747,7 +8248,7 @@ export function createDecisionServer(options) {
|
|
|
7747
8248
|
// under a ceiling. Unscoped mode keeps its historic unplaced filings.
|
|
7748
8249
|
if (!unscopedMode && effective === "") {
|
|
7749
8250
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7750
|
-
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));
|
|
8251
|
+
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()));
|
|
7751
8252
|
}
|
|
7752
8253
|
let repo = effective;
|
|
7753
8254
|
let admitted = unscopedMode ? null : admissionList() ?? [];
|
|
@@ -7756,7 +8257,7 @@ export function createDecisionServer(options) {
|
|
|
7756
8257
|
const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
|
|
7757
8258
|
if (canonical === null || canonical === undefined) {
|
|
7758
8259
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7759
|
-
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));
|
|
8260
|
+
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()));
|
|
7760
8261
|
}
|
|
7761
8262
|
repo = canonical;
|
|
7762
8263
|
admitted = [canonical];
|
|
@@ -7813,7 +8314,7 @@ export function createDecisionServer(options) {
|
|
|
7813
8314
|
});
|
|
7814
8315
|
if (!made.ok) {
|
|
7815
8316
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
7816
|
-
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));
|
|
8317
|
+
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()));
|
|
7817
8318
|
}
|
|
7818
8319
|
const actionContext = requestContext.getStore();
|
|
7819
8320
|
if (actionContext !== undefined)
|
|
@@ -8288,7 +8789,7 @@ export function createDecisionServer(options) {
|
|
|
8288
8789
|
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
8289
8790
|
return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
|
|
8290
8791
|
}
|
|
8291
|
-
const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
|
|
8792
|
+
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 });
|
|
8292
8793
|
if (!merged.ok)
|
|
8293
8794
|
return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
|
|
8294
8795
|
return redirect(response, `${taskHref(act.taskId)}#merge`);
|
|
@@ -8472,6 +8973,24 @@ export function createDecisionServer(options) {
|
|
|
8472
8973
|
store.retirePushSubscription(mine.id, `removed by ${who.name}`, clock());
|
|
8473
8974
|
return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
|
|
8474
8975
|
}
|
|
8976
|
+
if (url.pathname === "/onboarding/phone/dismiss") {
|
|
8977
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
8978
|
+
return refuse(response, who, 403, "An approver puts this away.", "/chat");
|
|
8979
|
+
store.recordInstallationFact(PHONE_CARD_FACT, who.name, now);
|
|
8980
|
+
return body.get("quiet") === "1" ? respond(response, 204, "text/plain; charset=utf-8", "") : redirect(response, "/chat");
|
|
8981
|
+
}
|
|
8982
|
+
// Turn the lead on with the agent signed in on this computer: its membership spends no dollars, so like starting a
|
|
8983
|
+
// conversation it needs no second password; the full form (Advanced) still asks for one.
|
|
8984
|
+
if (url.pathname === "/settings/lead/on") {
|
|
8985
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
8986
|
+
return refuse(response, who, 403, "An approver turns the lead on.", "/settings/lead");
|
|
8987
|
+
await checkLocalAgents(true);
|
|
8988
|
+
const provider = localSignIn?.states.claude === "connected" ? "claude-subscription" : localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
|
|
8989
|
+
if (provider === null)
|
|
8990
|
+
return redirect(response, chatReturnWithSaid("/settings/lead", `No agent is signed in on this computer yet. Run ${agentSignInCommand()}, then try again.`));
|
|
8991
|
+
store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
|
|
8992
|
+
return redirect(response, "/chat");
|
|
8993
|
+
}
|
|
8475
8994
|
if (url.pathname === "/chat/config") {
|
|
8476
8995
|
// The console's own door into `config set chat` (operator request:
|
|
8477
8996
|
// chat lives mainly in the web UI). Same ceremony weight as the CLI
|
|
@@ -8480,7 +8999,7 @@ export function createDecisionServer(options) {
|
|
|
8480
8999
|
// touches a form or this database — environment only.
|
|
8481
9000
|
if (who.via !== "cookie")
|
|
8482
9001
|
return refuse(response, who, 403, "chat setup is a browser surface");
|
|
8483
|
-
const back = safeChatReturn(body.get("return"));
|
|
9002
|
+
const back = body.get("return") === "/settings/lead" ? "/settings/lead" : safeChatReturn(body.get("return"));
|
|
8484
9003
|
const password = body.get("token") ?? "";
|
|
8485
9004
|
if (password === "" || !authenticateApprover(store, who.name, password).ok) {
|
|
8486
9005
|
return redirect(response, chatReturnWithSaid(back, "configuring chat spend takes your password, typed again"));
|
|
@@ -8746,6 +9265,14 @@ export function createDecisionServer(options) {
|
|
|
8746
9265
|
return said(409, "That result is no longer available for this task. Review the task before sending.");
|
|
8747
9266
|
}
|
|
8748
9267
|
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.`;
|
|
9268
|
+
// The task composer's mode (console v2): fixed words for this turn only.
|
|
9269
|
+
// It steers what the lead proposes and grants nothing: every action
|
|
9270
|
+
// still arrives as a card the person confirms under its own rules.
|
|
9271
|
+
const modeValue = body.get("mode");
|
|
9272
|
+
if (modeValue !== null && (focusTask === null || body.getAll("mode").length !== 1 || !Object.hasOwn(TASK_COMPOSER_MODES, modeValue))) {
|
|
9273
|
+
return said(400, "Choose Build, Plan only or Just answer, then send again. Your draft is saved.");
|
|
9274
|
+
}
|
|
9275
|
+
const modeContext = modeValue === null ? "" : ` ${TASK_COMPOSER_MODES[modeValue]}`;
|
|
8749
9276
|
const enabled = chatEnablement();
|
|
8750
9277
|
if (!enabled.ok)
|
|
8751
9278
|
return said(409, enabled.why);
|
|
@@ -8767,7 +9294,7 @@ export function createDecisionServer(options) {
|
|
|
8767
9294
|
}
|
|
8768
9295
|
const opened = store.openMateThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
|
|
8769
9296
|
const onProgress = beginLiveTurn(opened.thread.id);
|
|
8770
|
-
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 })
|
|
9297
|
+
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 })
|
|
8771
9298
|
.then(outcome => {
|
|
8772
9299
|
if (!outcome.ok)
|
|
8773
9300
|
noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
|
|
@@ -9277,6 +9804,33 @@ export function createDecisionServer(options) {
|
|
|
9277
9804
|
return refuse(response, who, result.status, result.message, back);
|
|
9278
9805
|
return redirect(response, revisionDestination(result.id, back));
|
|
9279
9806
|
}
|
|
9807
|
+
// Follow-ups on a result: Run checks on its exact commit (a worker runs the
|
|
9808
|
+
// approved command once and seals the log), or file a task to add tests.
|
|
9809
|
+
const followUp = /^\/r\/([0-9]{1,15})\/(checks|add-tests)$/.exec(url.pathname);
|
|
9810
|
+
if (followUp !== null) {
|
|
9811
|
+
const id = Number(followUp[1]);
|
|
9812
|
+
const found = store.getRun(id);
|
|
9813
|
+
if (found === null || !visible(taskRepoOf(found.taskRef)))
|
|
9814
|
+
return refuse(response, who, 404, "no such run");
|
|
9815
|
+
const back = resultReturnTarget(body.get("return"), id);
|
|
9816
|
+
if (who.via !== "cookie")
|
|
9817
|
+
return refuse(response, who, 403, "Sign in with a browser session to do this.", back);
|
|
9818
|
+
if (followUp[2] === "checks") {
|
|
9819
|
+
if (who.role !== "approver")
|
|
9820
|
+
return refuse(response, who, 403, "An approver runs checks.", back);
|
|
9821
|
+
if (store.isDemo())
|
|
9822
|
+
return refuse(response, who, 403, "The demo runs no checks.", back);
|
|
9823
|
+
const level = body.get("level") === "full" ? "full" : "quick";
|
|
9824
|
+
const asked = requestFollowUpChecks(store, { runId: id, level, actor: who.name }, now);
|
|
9825
|
+
if (!asked.ok)
|
|
9826
|
+
return refuse(response, who, 409, asked.message, back);
|
|
9827
|
+
return redirect(response, `${back.split("#")[0]}#follow-ups`);
|
|
9828
|
+
}
|
|
9829
|
+
const filed = fileAddTestsTask(store, evidenceRoot, { runId: id, actor: who.name, filedVia: "console", ...(admissionList() === null ? {} : { admittedRepos: admissionList() }) }, now);
|
|
9830
|
+
if (!filed.ok)
|
|
9831
|
+
return refuse(response, who, 409, filed.message, back);
|
|
9832
|
+
return redirect(response, taskHref(filed.id));
|
|
9833
|
+
}
|
|
9280
9834
|
const draftRepair = /^\/r\/([0-9]{1,15})\/draft-repair$/.exec(url.pathname);
|
|
9281
9835
|
if (draftRepair !== null) {
|
|
9282
9836
|
// CI repair, suggestion-first (M8.18): a red episode never spawns an
|
|
@@ -9950,7 +10504,7 @@ export function createDecisionServer(options) {
|
|
|
9950
10504
|
}
|
|
9951
10505
|
case "cancel": {
|
|
9952
10506
|
const reason = body.get("reason") ?? undefined;
|
|
9953
|
-
const cancelled = store.cancelTask(taskId, now, reason);
|
|
10507
|
+
const cancelled = withActor({ account: who.name, lead: false }, () => store.cancelTask(taskId, now, reason));
|
|
9954
10508
|
if (!cancelled.ok) {
|
|
9955
10509
|
if (cancelled.reason === "reason-required" || cancelled.reason === "bad-reason") {
|
|
9956
10510
|
return taskScreen(response, who, taskId, cancelled.reason === "reason-required"
|
|
@@ -10878,6 +11432,7 @@ export function createDecisionServer(options) {
|
|
|
10878
11432
|
return [{ id: one.id, title: one.title, state: one.state, approved: approvalOf(store.getScope(one.id)).approved, standing: status.label, tone: status.tone }];
|
|
10879
11433
|
}),
|
|
10880
11434
|
sourceDigest: scope?.digest ?? null,
|
|
11435
|
+
followUps: followUpsFor(store, evidenceRoot, run, now),
|
|
10881
11436
|
route: store.runRoute(run.id),
|
|
10882
11437
|
// Editor links (arc 6): the deployment capability, THIS machine's
|
|
10883
11438
|
// runner owning the run, the session's own device-side yes, and a
|
|
@@ -11057,7 +11612,7 @@ export function createDecisionServer(options) {
|
|
|
11057
11612
|
});
|
|
11058
11613
|
return server;
|
|
11059
11614
|
});
|
|
11060
|
-
return server;
|
|
11615
|
+
return Object.assign(server, { mintSignInLink });
|
|
11061
11616
|
}
|
|
11062
11617
|
// ---- path plumbing ---------------------------------------------------------
|
|
11063
11618
|
/**
|
|
@@ -11242,7 +11797,8 @@ export function editorFileHref(worktree, path, line) {
|
|
|
11242
11797
|
* chat (v39, extending Priority 2's "one surface, six places" rule from
|
|
11243
11798
|
* the verdict word to the per-criterion state). */
|
|
11244
11799
|
function matrixStateBadge(state) {
|
|
11245
|
-
|
|
11800
|
+
// Red belongs to a Failed headline alone (task-status.ts); an unmet requirement is an amber note.
|
|
11801
|
+
const cls = state === "pass" ? "badge-done" : state === "failed" ? "badge-note" : state === "missing" ? "badge-note" : "badge-manual-review";
|
|
11246
11802
|
const word = state === "pass" ? "pass" : state === "missing" ? "missing" : state === "failed" ? "failed" : "manual review";
|
|
11247
11803
|
return `<span class="badge ${cls}" data-matrix-state="${escape(state)}">${escape(word)}</span>`;
|
|
11248
11804
|
}
|
|
@@ -11328,7 +11884,7 @@ function criterionMatrixHtml(matrix, options = {}) {
|
|
|
11328
11884
|
if (matrix.length === 0)
|
|
11329
11885
|
return "";
|
|
11330
11886
|
const kinds = {
|
|
11331
|
-
check: "Checks", "changed-path": "Changed files", screenshot: "Screenshots", "manual-review": "
|
|
11887
|
+
check: "Checks", "changed-path": "Changed files", screenshot: "Screenshots", "manual-review": "You check",
|
|
11332
11888
|
};
|
|
11333
11889
|
const evidenceLink = (a) => {
|
|
11334
11890
|
const text = `<code>${escape(a.ref)}</code>`;
|
|
@@ -11344,8 +11900,9 @@ function criterionMatrixHtml(matrix, options = {}) {
|
|
|
11344
11900
|
const state = row.state;
|
|
11345
11901
|
const awaitingAssessment = row.assessment?.evidenceState === "pass" && row.review === null;
|
|
11346
11902
|
const confirmed = row.assessment !== undefined && state === "pass";
|
|
11347
|
-
|
|
11348
|
-
const
|
|
11903
|
+
// Plain words (task-status brief): the retired assessment step is not a state a person acts on.
|
|
11904
|
+
const label = awaitingAssessment ? "Met" : confirmed ? "Confirmed" : state === "pass" ? "Met" : state === "manual-review" ? "You check" : state === "missing" ? "Not shown yet" : "Not met";
|
|
11905
|
+
const cls = awaitingAssessment ? "badge-done" : state === "pass" ? "badge-done" : state === "manual-review" ? "badge-manual-review" : "badge-note";
|
|
11349
11906
|
const warnings = [];
|
|
11350
11907
|
// Only replace the known boilerplate. Other recorded failure details
|
|
11351
11908
|
// remain verbatim, so concision cannot hide a different problem.
|
|
@@ -11744,7 +12301,7 @@ function agentsStripHtml(view, taskId) {
|
|
|
11744
12301
|
return `<details class="task-chat-agents"><summary>Agent setup</summary><p>${escape(agentsSummaryWords(view))}</p><a href="${taskHref(taskId)}#agents">View agents</a></details>`;
|
|
11745
12302
|
}
|
|
11746
12303
|
/** The demo's one-line promise, on every page. */
|
|
11747
|
-
const DEMO_BANNER =
|
|
12304
|
+
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.`;
|
|
11748
12305
|
/** Every character that could open a tag or an attribute, dead at the sink. */
|
|
11749
12306
|
function escape(text) {
|
|
11750
12307
|
return text
|
|
@@ -11978,6 +12535,10 @@ ${THEME_DARK}
|
|
|
11978
12535
|
background: color-mix(in srgb, var(--muted) 58%, var(--card));
|
|
11979
12536
|
color: color-mix(in srgb, var(--destructive) 68%, var(--foreground)); border-color: var(--border);
|
|
11980
12537
|
}
|
|
12538
|
+
.badge-note {
|
|
12539
|
+
background: color-mix(in srgb, var(--muted) 58%, var(--card));
|
|
12540
|
+
color: var(--so-warning, #ab6400); border-color: var(--border);
|
|
12541
|
+
}
|
|
11981
12542
|
.badge-overdue {
|
|
11982
12543
|
background: color-mix(in srgb, var(--muted) 58%, var(--card)); color: var(--foreground);
|
|
11983
12544
|
border-color: var(--border);
|
|
@@ -12734,6 +13295,8 @@ ${THEME_DARK}
|
|
|
12734
13295
|
@keyframes demo-pulse { 50% { opacity: .35; } }
|
|
12735
13296
|
@media (prefers-reduced-motion: reduce) { .demo-steps li.now::before { animation: none; } }
|
|
12736
13297
|
.demo-state { display: flex; align-items: center; gap: .5rem; margin: 0 !important; }
|
|
13298
|
+
.demo-chat .demo-handoff { border-color: var(--so-input-line); }
|
|
13299
|
+
.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; }
|
|
12737
13300
|
.demo-chat .badge.demo-ready { background: var(--so-info-soft); color: var(--so-info); }
|
|
12738
13301
|
.demo-chat .badge.demo-complete { background: var(--so-success-soft); color: var(--so-success); }
|
|
12739
13302
|
.demo-pass { color: var(--so-success); font-weight: 500; }
|
|
@@ -12818,8 +13381,9 @@ ${THEME_DARK}
|
|
|
12818
13381
|
.dispatch-status[data-dispatch-status="proof-refuted"] .dispatch-copy > strong::before,
|
|
12819
13382
|
.dispatch-status[data-dispatch-status="needs-verification"] .dispatch-copy > strong::before {
|
|
12820
13383
|
content: ""; display: inline-block; width: .45rem; height: .45rem; margin-right: .45rem; border-radius: 50%; vertical-align: .08rem;
|
|
12821
|
-
background: var(--
|
|
13384
|
+
background: var(--so-warning, #ab6400);
|
|
12822
13385
|
}
|
|
13386
|
+
.problem.dispatch-status[data-dispatch-status="proof-refuted"] .dispatch-copy > strong::before { background: var(--destructive); }
|
|
12823
13387
|
.dispatch-status[data-dispatch-status="needs-verification"] .dispatch-copy > strong::before { background: var(--muted-foreground); }
|
|
12824
13388
|
/* A review in flight (review fixes): the box reads neutral — neither the
|
|
12825
13389
|
green of a settled verdict nor the red of a problem — with the dot in
|
|
@@ -13320,7 +13884,7 @@ ${THEME_DARK}
|
|
|
13320
13884
|
.pull-request-head strong { font-size: 1.0625rem; font-weight: 600; color: var(--foreground); display: inline-flex; align-items: center; gap: .5rem; }
|
|
13321
13885
|
.pull-request-head strong::before { content: ""; width: .5rem; height: .5rem; border-radius: 999px; background: var(--muted-foreground); }
|
|
13322
13886
|
.pull-request--ok .pull-request-head strong::before { background: var(--success, #15803d); }
|
|
13323
|
-
.pull-request--problem .pull-request-head strong::before { background: var(--
|
|
13887
|
+
.pull-request--problem .pull-request-head strong::before { background: var(--so-warning, #ab6400); }
|
|
13324
13888
|
.pull-request-act { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem .75rem; margin: .25rem 0 0; }
|
|
13325
13889
|
.pull-request-act .meta { flex: 1 1 100%; }
|
|
13326
13890
|
.pull-request-act label { display: grid; gap: .25rem; flex: 1 1 12rem; margin: 0; }
|
|
@@ -14513,7 +15077,15 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
|
|
|
14513
15077
|
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}`;
|
|
14514
15078
|
/** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
|
|
14515
15079
|
export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
|
|
14516
|
-
|
|
15080
|
+
/** The Inbox tabs (console v2): a segmented track of real links; the dot marks a tab with something new, on phones only. */
|
|
15081
|
+
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}' +
|
|
15082
|
+
'.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}' +
|
|
15083
|
+
'.inbox-tabs a[aria-current="page"]{background:var(--so-paper);color:var(--so-ink);box-shadow:var(--so-pill-shadow)}' +
|
|
15084
|
+
'.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}' +
|
|
15085
|
+
'.inbox-tab-count--needs{background:var(--so-signal);color:var(--so-on-signal)}' +
|
|
15086
|
+
'.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
|
|
15087
|
+
'@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}}';
|
|
15088
|
+
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}');
|
|
14517
15089
|
/**
|
|
14518
15090
|
* The board's liveness: fetch this page's own fragment on a timer and swap
|
|
14519
15091
|
* it in place — no flicker, no scroll reset, no long-lived stream to manage,
|
|
@@ -15063,6 +15635,37 @@ function loginPage(problem, returnTo = "/", sso = null) {
|
|
|
15063
15635
|
`</div></div>`,
|
|
15064
15636
|
].join("\n"), { nav: false });
|
|
15065
15637
|
}
|
|
15638
|
+
/**
|
|
15639
|
+
* The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
|
|
15640
|
+
* command that admits this address. It stands alone (styles inline, no script, no font), because every other asset
|
|
15641
|
+
* would be refused at this address too; and it holds nothing a stranger's page doesn't already know.
|
|
15642
|
+
*/
|
|
15643
|
+
export function wrongHostPage(facts) {
|
|
15644
|
+
const code = (text) => `<code>${escape(text)}</code>`;
|
|
15645
|
+
return [
|
|
15646
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
15647
|
+
`<meta name="color-scheme" content="light dark"><title>Toolroll isn't set up for this address</title>`,
|
|
15648
|
+
`<style>`,
|
|
15649
|
+
`:root{color-scheme:light dark;--ground:#efefef;--paper:#fff;--ink:#171717;--muted:#666;--line:#e6e6e6;--soft:#f2f2f2}`,
|
|
15650
|
+
`@media (prefers-color-scheme:dark){:root{--ground:#0b0b0b;--paper:#161616;--ink:#ededed;--muted:#a1a1a1;--line:#262626;--soft:#1f1f1f}}`,
|
|
15651
|
+
`*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;background:var(--ground);color:var(--ink);`,
|
|
15652
|
+
`font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}`,
|
|
15653
|
+
`main{width:100%;max-width:520px;background:var(--paper);border:1px solid var(--line);border-radius:12px;padding:28px}`,
|
|
15654
|
+
`.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}`,
|
|
15655
|
+
`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}`,
|
|
15656
|
+
`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}`,
|
|
15657
|
+
`.meta{color:var(--muted);font-size:13px;margin:0}`,
|
|
15658
|
+
`</style></head><body><main>`,
|
|
15659
|
+
`<p class="mark">Toolroll</p>`,
|
|
15660
|
+
`<h1>Toolroll isn't set up for this address</h1>`,
|
|
15661
|
+
facts.opened === null
|
|
15662
|
+
? `<p>It answers at ${code(facts.served)} on the computer running it.</p>`
|
|
15663
|
+
: `<p>You opened it at ${code(facts.opened)}. It answers at ${code(facts.served)} on the computer running it.</p>`,
|
|
15664
|
+
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>`,
|
|
15665
|
+
`<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>`,
|
|
15666
|
+
`</main></body></html>`,
|
|
15667
|
+
].join("\n");
|
|
15668
|
+
}
|
|
15066
15669
|
/** The first-account page (setup review): shown only while no approver exists. */
|
|
15067
15670
|
function signupPage(problem, attemptsLeft) {
|
|
15068
15671
|
return shell("Toolroll", [
|
|
@@ -15086,12 +15689,22 @@ function signupPage(problem, attemptsLeft) {
|
|
|
15086
15689
|
`</div></div>`,
|
|
15087
15690
|
].join("\n"), { nav: false });
|
|
15088
15691
|
}
|
|
15089
|
-
|
|
15090
|
-
|
|
15091
|
-
|
|
15092
|
-
|
|
15093
|
-
|
|
15094
|
-
|
|
15692
|
+
const INBOX_TABS = [
|
|
15693
|
+
{ id: "needs-you", label: "Needs you" }, { id: "ready", label: "Ready" }, { id: "running", label: "Running" }, { id: "all", label: "All" },
|
|
15694
|
+
];
|
|
15695
|
+
export function parseInboxTab(raw) {
|
|
15696
|
+
return INBOX_TABS.some(one => one.id === raw) ? raw : undefined;
|
|
15697
|
+
}
|
|
15698
|
+
/** What each tab holds, as a short fingerprint: a tab is unread when its
|
|
15699
|
+
* fingerprint differs from the one this browser saw there last. */
|
|
15700
|
+
export function inboxFingerprints(data) {
|
|
15701
|
+
const print = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex").slice(0, 8);
|
|
15702
|
+
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}`),
|
|
15703
|
+
...data.cancelledBlockers.map(one => `c${one.blockerId}`), ...data.gaps.map(one => `g${one.key}`)];
|
|
15704
|
+
const ready = [...data.needsVerification.map(one => `v${one.taskId}`), ...(data.ready ?? []).map(one => `r${one.taskId}`)];
|
|
15705
|
+
const running = (data.running ?? []).map(one => `u${one.taskId}`);
|
|
15706
|
+
return { "needs-you": print(needs), ready: print(ready), running: print(running), all: print([...needs, ...ready, ...running]) };
|
|
15707
|
+
}
|
|
15095
15708
|
function inboxPage(chrome, data) {
|
|
15096
15709
|
/** The row's project, worn openly in the roll-up — null is UNPLACED,
|
|
15097
15710
|
* said as such, never a silent missing chip (finding 13). */
|
|
@@ -15185,19 +15798,41 @@ function inboxPage(chrome, data) {
|
|
|
15185
15798
|
? `<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.`
|
|
15186
15799
|
: `<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.`) +
|
|
15187
15800
|
`</div>`;
|
|
15801
|
+
// Console v2: Needs you · Ready · Running · All, as real links (Back and
|
|
15802
|
+
// bookmarks work). Each section belongs to one tab; All shows every one.
|
|
15803
|
+
const tab = data.tab ?? "all";
|
|
15804
|
+
const shows = (one) => tab === "all" || tab === one;
|
|
15805
|
+
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");
|
|
15806
|
+
const ready = (data.ready ?? []).length === 0 ? "" : `<h2>ready to review</h2>` + listRows(data.ready ?? []);
|
|
15807
|
+
const running = (data.running ?? []).length === 0 ? "" : `<h2>running now</h2>` + listRows(data.running ?? []);
|
|
15808
|
+
const counts = {
|
|
15809
|
+
"needs-you": data.decisions.length + data.approvals.length + data.requeueables.length + data.cancelledBlockers.length + data.gaps.length,
|
|
15810
|
+
ready: data.needsVerification.length + (data.ready ?? []).length,
|
|
15811
|
+
running: (data.running ?? []).length,
|
|
15812
|
+
all: 0,
|
|
15813
|
+
};
|
|
15814
|
+
counts.all = counts["needs-you"] + counts.ready + counts.running;
|
|
15815
|
+
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}` +
|
|
15816
|
+
`<span class="inbox-tab-count${one.id === "needs-you" && counts[one.id] > 0 ? " inbox-tab-count--needs" : ""}">${counts[one.id]}</span>` +
|
|
15817
|
+
`${one.id !== tab && (data.unread ?? []).includes(one.id) ? `<span class="inbox-unread" aria-label="new"></span>` : ""}</a>`).join("") + `</nav>`;
|
|
15818
|
+
const tabEmpty = data.tab !== undefined && tab !== "all" && counts[tab] === 0
|
|
15819
|
+
? `<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>` : "";
|
|
15188
15820
|
return screen("inbox", [
|
|
15189
15821
|
`<h1>Inbox</h1>`,
|
|
15190
15822
|
`<p class="meta">everything that waits on you \u2014 empty means the fleet is working</p>`,
|
|
15823
|
+
tabs,
|
|
15191
15824
|
noWorker,
|
|
15192
15825
|
wizard,
|
|
15193
|
-
empty ? "" : `<p><a class="new-task" style="display:inline-block" href="/next">clear the queue \u2192 one thing at a time</a></p>`,
|
|
15194
|
-
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>` :
|
|
15195
|
-
decisions,
|
|
15196
|
-
approvals,
|
|
15197
|
-
requeueables,
|
|
15198
|
-
needsVerification,
|
|
15199
|
-
|
|
15200
|
-
|
|
15826
|
+
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>`,
|
|
15827
|
+
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,
|
|
15828
|
+
shows("needs-you") ? decisions : "",
|
|
15829
|
+
shows("needs-you") ? approvals : "",
|
|
15830
|
+
shows("needs-you") ? requeueables : "",
|
|
15831
|
+
shows("ready") ? needsVerification : "",
|
|
15832
|
+
shows("ready") ? ready : "",
|
|
15833
|
+
shows("running") ? running : "",
|
|
15834
|
+
shows("needs-you") ? cancelled : "",
|
|
15835
|
+
shows("needs-you") ? gaps : "",
|
|
15201
15836
|
data.rollup
|
|
15202
15837
|
? `<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>`
|
|
15203
15838
|
: "",
|
|
@@ -15622,6 +16257,11 @@ function approvalFormDigest(scopeDigest, raceDigest, planSha) {
|
|
|
15622
16257
|
.digest("hex");
|
|
15623
16258
|
}
|
|
15624
16259
|
const taskChatHref = (taskId) => `/chat?task=${encodeURIComponent(taskId)}`;
|
|
16260
|
+
export const TASK_COMPOSER_MODES = {
|
|
16261
|
+
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.",
|
|
16262
|
+
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.",
|
|
16263
|
+
answer: "The operator chose Just answer: answer from what you can read. Do not propose any action this turn.",
|
|
16264
|
+
};
|
|
15625
16265
|
/** A project's own lead thread (v77). */
|
|
15626
16266
|
const projectChatHref = (repo) => `/chat?project=${encodeURIComponent(repo)}`;
|
|
15627
16267
|
/** The one task-level road from a truthful dispatch diagnosis to its nearest
|
|
@@ -15903,6 +16543,7 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
|
|
|
15903
16543
|
? `<details class="task-secondary-approval"><summary>Updated approval terms</summary>${approvalContent}</details>` : approvalContent;
|
|
15904
16544
|
const polling = !inert && ((focus.approval === null || focus.plan === "requested") && focus.state !== "done" && focus.state !== "cancelled" || focus.control.kind === "stopping" || focus.control.kind === "stop");
|
|
15905
16545
|
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 ?? "")}">` +
|
|
16546
|
+
(focus.guide === true && focus.state !== "cancelled" ? firstTaskJourneyHtml(focus) : "") +
|
|
15906
16547
|
`<div class="task-live-summary">` +
|
|
15907
16548
|
(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")) +
|
|
15908
16549
|
checkProgressHtml(focus.checkProgress) +
|
|
@@ -15926,6 +16567,16 @@ function taskChatLiveRegion(focus, csrf, fragment = false, inert = false, approv
|
|
|
15926
16567
|
(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>`) +
|
|
15927
16568
|
`</section>`);
|
|
15928
16569
|
}
|
|
16570
|
+
/** The first task's way to Ready, filled in as it moves (onboarding): the same stage every surface reads. */
|
|
16571
|
+
function firstTaskJourneyHtml(focus) {
|
|
16572
|
+
const planning = focus.plan === "requested" && focus.approval === null;
|
|
16573
|
+
const read = focus.assignment !== null ? assignmentStageOf(focus.assignment, { token: focus.status.token }, planning) : stageOfCode(focus.status.token, { planning });
|
|
16574
|
+
const steps = firstTaskJourney(read, focus.scope === "approved", focus.state === "done");
|
|
16575
|
+
const current = steps.find(one => one.state === "current" || one.state === "stuck");
|
|
16576
|
+
return `<ol class="first-task-journey" aria-label="Where this task is${current === undefined ? ": Ready" : `: ${escape(current.label)}`}" data-first-task-journey>` +
|
|
16577
|
+
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("") +
|
|
16578
|
+
`</ol>`;
|
|
16579
|
+
}
|
|
15929
16580
|
function taskChatHeading(focus) {
|
|
15930
16581
|
return (`<div class="chat-head task-chat-head"><div>` +
|
|
15931
16582
|
`<p class="meta chat-task-back"><a href="${taskHref(focus.id)}">← task overview</a></p>` +
|
|
@@ -15975,7 +16626,7 @@ function chatFleetOverview(snapshot, projects, csrf, interactive) {
|
|
|
15975
16626
|
const dispatch = task.dispatch;
|
|
15976
16627
|
rows.push(`<a class="chat-overview-item decision" href="${taskHref(task.rootId ?? task.id)}" data-dispatch-status="${escape(dispatch.code)}">` +
|
|
15977
16628
|
`<span class="chat-overview-icon">${strokeIcon(`<path d="M12 8v4"/><path d="M12 16h.01"/><circle cx="12" cy="12" r="9"/>`)}</span>` +
|
|
15978
|
-
`<span class="chat-overview-copy"><strong>${escape(task.title)}</strong><span>${escape(projectOf(task.repoIndex))} · ${escape(task.id)} · ${escape(dispatch
|
|
16629
|
+
`<span class="chat-overview-copy"><strong>${escape(task.title)}</strong><span>${escape(projectOf(task.repoIndex))} · ${escape(task.id)} · ${escape(dispatchHeadline(dispatch))}</span></span>` +
|
|
15979
16630
|
`<span class="chat-overview-arrow" aria-hidden="true">→</span></a>`);
|
|
15980
16631
|
}
|
|
15981
16632
|
for (const task of snapshot.tasks.filter(one => one.state === "failed").slice(0, Math.max(0, 3 - rows.length))) {
|
|
@@ -16185,55 +16836,83 @@ function chatLimitsHtml(facts) {
|
|
|
16185
16836
|
rows.push(`<span>this week ${chatMoney(facts.weekly.spent)} of ${chatMoney(facts.weekly.ceiling)}</span>`);
|
|
16186
16837
|
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>`;
|
|
16187
16838
|
}
|
|
16839
|
+
/** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
|
|
16840
|
+
function leadSettingsHtml(data) {
|
|
16841
|
+
const { config, facts } = data;
|
|
16842
|
+
const hidden = `<input type="hidden" name="csrf" value="${escape(facts.csrf)}"><input type="hidden" name="return" value="/settings/lead">`;
|
|
16843
|
+
const summary = config === null
|
|
16844
|
+
? data.signedIn !== null
|
|
16845
|
+
? `<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>`
|
|
16846
|
+
: `<p>The lead is off. Sign in an agent on this computer to turn it on:</p><p><code>${escape(data.command)}</code></p>`
|
|
16847
|
+
: data.words !== null
|
|
16848
|
+
? `<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>`
|
|
16849
|
+
: `<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>`;
|
|
16850
|
+
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)}">` +
|
|
16851
|
+
`<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("");
|
|
16852
|
+
return [
|
|
16853
|
+
`<p><a href="/settings">Settings</a></p><h1>Lead</h1>`,
|
|
16854
|
+
data.said === null ? "" : `<p class="problem" role="status">${escape(data.said)}</p>`,
|
|
16855
|
+
`<section class="card lead-settings" data-lead-settings>${summary}</section>`,
|
|
16856
|
+
`<details class="lead-advanced" data-lead-advanced><summary>Advanced</summary>`,
|
|
16857
|
+
leadConfigForm(config, facts),
|
|
16858
|
+
config === null ? "" : `<form method="post" action="/chat/config" class="inline">${hidden}<input type="hidden" name="off" value="1">` +
|
|
16859
|
+
`<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>`,
|
|
16860
|
+
forget,
|
|
16861
|
+
`</details>`,
|
|
16862
|
+
].join("\n");
|
|
16863
|
+
}
|
|
16864
|
+
/** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and a direct API key. */
|
|
16865
|
+
function leadConfigForm(current, data) {
|
|
16866
|
+
const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
|
|
16867
|
+
const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
|
|
16868
|
+
const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
|
|
16869
|
+
const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
|
|
16870
|
+
const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
|
|
16871
|
+
return [
|
|
16872
|
+
`<form method="post" action="/chat/config" class="card">`,
|
|
16873
|
+
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
|
|
16874
|
+
`<input type="hidden" name="return" value="${escape(data.returnTo)}">`,
|
|
16875
|
+
`<label>provider<select name="provider">`,
|
|
16876
|
+
`<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
|
|
16877
|
+
`<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
|
|
16878
|
+
`<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
|
|
16879
|
+
`<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
|
|
16880
|
+
`</select></label>`,
|
|
16881
|
+
`<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
|
|
16882
|
+
`<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
|
|
16883
|
+
`${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
|
|
16884
|
+
data.openrouterModels === null
|
|
16885
|
+
? `<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>`
|
|
16886
|
+
: `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
|
|
16887
|
+
currentSubscription
|
|
16888
|
+
? `<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>`
|
|
16889
|
+
: `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
|
|
16890
|
+
`<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
|
|
16891
|
+
`<label>daily turns <span class="meta">(default 50)</span>` +
|
|
16892
|
+
`<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
|
|
16893
|
+
currentSubscription
|
|
16894
|
+
? `<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>`
|
|
16895
|
+
: `<label>API key <span class="meta">(${data.keyFacts
|
|
16896
|
+
.map(one => one.state === "none"
|
|
16897
|
+
? `${escape(one.provider)}: none yet`
|
|
16898
|
+
: one.state === "environment"
|
|
16899
|
+
? `${escape(one.provider)}: from the environment`
|
|
16900
|
+
: `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
|
|
16901
|
+
.join(" · ")})</span>` +
|
|
16902
|
+
`<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
|
|
16903
|
+
`<label>your password <span class="meta">(typed again to change the provider)</span>` +
|
|
16904
|
+
`<input type="password" name="token" autocomplete="current-password"></label>`,
|
|
16905
|
+
`<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
|
|
16906
|
+
`</form>`,
|
|
16907
|
+
currentSubscription
|
|
16908
|
+
? `<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>`
|
|
16909
|
+
: `<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 (` +
|
|
16910
|
+
`<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
|
|
16911
|
+
].join("\n");
|
|
16912
|
+
}
|
|
16188
16913
|
function chatPage(chrome, data) {
|
|
16189
|
-
const
|
|
16190
|
-
|
|
16191
|
-
const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
|
|
16192
|
-
const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
|
|
16193
|
-
const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
|
|
16194
|
-
const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
|
|
16195
|
-
return [
|
|
16196
|
-
`<form method="post" action="/chat/config" class="card">`,
|
|
16197
|
-
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
|
|
16198
|
-
`<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">`,
|
|
16199
|
-
`<label>provider<select name="provider">`,
|
|
16200
|
-
`<option value="codex-subscription"${current?.provider === "codex-subscription" ? " selected" : ""}>Codex membership (logged-in CLI)</option>`,
|
|
16201
|
-
`<option value="claude-subscription"${current?.provider === "claude-subscription" ? " selected" : ""}>Anthropic membership (logged-in CLI)</option>`,
|
|
16202
|
-
`<option value="anthropic-api"${current?.provider === "anthropic-api" ? " selected" : ""}>anthropic-api (direct API)</option>`,
|
|
16203
|
-
`<option value="openrouter-api"${current?.provider === "openrouter-api" ? " selected" : ""}>openrouter-api (direct API)</option>`,
|
|
16204
|
-
`</select></label>`,
|
|
16205
|
-
`<label>model <span class="meta">(use default for your membership's current model; direct API models need a pinned price)</span>` +
|
|
16206
|
-
`<input name="model" list="chat-models" value="${escape(current?.model ?? "default")}"><datalist id="chat-models">` +
|
|
16207
|
-
`${models.map(model => `<option value="${escape(model)}">${escape(labels.get(model) ?? "")}</option>`).join("")}</datalist></label>`,
|
|
16208
|
-
data.openrouterModels === null
|
|
16209
|
-
? `<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>`
|
|
16210
|
-
: `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
|
|
16211
|
-
currentSubscription
|
|
16212
|
-
? `<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>`
|
|
16213
|
-
: `<label>weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>` +
|
|
16214
|
-
`<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
|
|
16215
|
-
`<label>daily turns <span class="meta">(default 50)</span>` +
|
|
16216
|
-
`<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
|
|
16217
|
-
currentSubscription
|
|
16218
|
-
? `<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>`
|
|
16219
|
-
: `<label>API key <span class="meta">(${data.keyFacts
|
|
16220
|
-
.map(one => one.state === "none"
|
|
16221
|
-
? `${escape(one.provider)}: none yet`
|
|
16222
|
-
: one.state === "environment"
|
|
16223
|
-
? `${escape(one.provider)}: from the environment`
|
|
16224
|
-
: `${escape(one.provider)}: stored ${escape(one.tail ?? "")}`)
|
|
16225
|
-
.join(" · ")})</span>` +
|
|
16226
|
-
`<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
|
|
16227
|
-
`<label>your password <span class="meta">(typed again to change the provider)</span>` +
|
|
16228
|
-
`<input type="password" name="token" autocomplete="current-password"></label>`,
|
|
16229
|
-
`<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
|
|
16230
|
-
`</form>`,
|
|
16231
|
-
currentSubscription
|
|
16232
|
-
? `<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>`
|
|
16233
|
-
: `<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 (` +
|
|
16234
|
-
`<span class="mono">ANTHROPIC_API_KEY</span> / <span class="mono">OPENROUTER_API_KEY</span>) always wins when set</p>`,
|
|
16235
|
-
].join("\n");
|
|
16236
|
-
};
|
|
16914
|
+
const formFacts = { keyFacts: data.keyFacts, openrouterModels: data.openrouterModels, ...(data.liveModels === undefined ? {} : { liveModels: data.liveModels }), csrf: data.csrf,
|
|
16915
|
+
returnTo: data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id) };
|
|
16237
16916
|
const parts = [
|
|
16238
16917
|
data.focusTask === null
|
|
16239
16918
|
? chatHeading("", data.projects.length, data.enabled.ok)
|
|
@@ -16246,14 +16925,17 @@ function chatPage(chrome, data) {
|
|
|
16246
16925
|
const code = data.enabled.code;
|
|
16247
16926
|
// The sandbox shows no conversation at all: chat evidence is a real
|
|
16248
16927
|
// subscription-backed plane, never a seeded transcript (v48 authority repair).
|
|
16249
|
-
|
|
16250
|
-
|
|
16251
|
-
|
|
16252
|
-
|
|
16253
|
-
|
|
16254
|
-
|
|
16255
|
-
|
|
16256
|
-
|
|
16928
|
+
// With no lead yet, the first run says what happens next (it turns on with the signed-in agent, or shows the one
|
|
16929
|
+
// command to run); otherwise one line, and its settings live in Settings → Lead (onboarding).
|
|
16930
|
+
const settingsLink = data.canManage ? ` <a href="/settings/lead">Settings → Lead</a>` : "";
|
|
16931
|
+
if (!(code === "unconfigured" && data.firstRunShown === true))
|
|
16932
|
+
parts.push(code === "demo"
|
|
16933
|
+
? `<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>`
|
|
16934
|
+
: code === "unconfigured"
|
|
16935
|
+
? `<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>`
|
|
16936
|
+
: code === "unpriced" || code === "no-key"
|
|
16937
|
+
? `<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>`
|
|
16938
|
+
: `<div class="card" id="latest"><p><strong>chat is off.</strong></p><p class="meta">${escape(data.enabled.why)}</p></div>`);
|
|
16257
16939
|
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 } });
|
|
16258
16940
|
}
|
|
16259
16941
|
const config = data.enabled.config;
|
|
@@ -16309,15 +16991,7 @@ function chatPage(chrome, data) {
|
|
|
16309
16991
|
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>`);
|
|
16310
16992
|
}
|
|
16311
16993
|
if (data.canManage) {
|
|
16312
|
-
parts.push(`<
|
|
16313
|
-
.filter(one => one.state === "stored")
|
|
16314
|
-
.map(one => `<form method="post" action="/chat/config" class="inline">` +
|
|
16315
|
-
`<input type="hidden" name="csrf" value="${escape(data.csrf)}">` +
|
|
16316
|
-
`<input type="hidden" name="return" value="${escape(data.focusTask === null ? "/chat" : taskChatHref(data.focusTask.id))}">` +
|
|
16317
|
-
`<input type="hidden" name="forget-key" value="${escape(one.provider)}">` +
|
|
16318
|
-
`<input type="password" name="token" placeholder="your password" autocomplete="current-password">` +
|
|
16319
|
-
`<button type="submit">forget the stored ${escape(one.provider)} key</button></form>`)
|
|
16320
|
-
.join("\n"), `</details>`);
|
|
16994
|
+
parts.push(`<p class="meta" id="chat-settings"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`);
|
|
16321
16995
|
}
|
|
16322
16996
|
if (data.recent.length > 0) {
|
|
16323
16997
|
parts.push(`<details class="chat-turn-history"><summary>Conversation activity <span class="meta">${data.recent.length} recent turns</span></summary>`);
|
|
@@ -16434,7 +17108,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
|
|
|
16434
17108
|
: "inspect first when the work needs repository context";
|
|
16435
17109
|
what =
|
|
16436
17110
|
`<h3>${escape(text("title"))}</h3><p class="proposal-summary">${escape(text("goal"))}</p>` +
|
|
16437
|
-
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>") : ""]);
|
|
17111
|
+
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>") : ""]);
|
|
16438
17112
|
}
|
|
16439
17113
|
else if (view.kind === "action") {
|
|
16440
17114
|
const action = sharedActionPayload(payload);
|
|
@@ -16797,7 +17471,7 @@ function matePage(chrome, data) {
|
|
|
16797
17471
|
: `<p class="meta">recent turns: ${data.recent
|
|
16798
17472
|
.map(turn => `<span class="mono">#${turn.id}</span> ${escape(turn.state)}${turn.failureReason === null ? "" : ` · ${escape(turn.failureReason)}`} · ${subscription ? "membership" : chatMoney(turn.settledMicrousd ?? turn.reservedMicrousd)}`)
|
|
16799
17473
|
.join(" · ")}</p>`,
|
|
16800
|
-
`<p class="meta"><a href="/
|
|
17474
|
+
`<p class="meta"><a href="/settings/lead">Lead settings</a> · provider, model and limits</p>`,
|
|
16801
17475
|
`</details>`,
|
|
16802
17476
|
].join("\n");
|
|
16803
17477
|
const conversation = [
|
|
@@ -17212,6 +17886,11 @@ function statusActionHref(status, taskId, runId, prUrl, repo) {
|
|
|
17212
17886
|
}
|
|
17213
17887
|
/** The one status line every surface renders: a dot in the tone, the
|
|
17214
17888
|
* label, and the `data-work-status` token tests and CSS key off. */
|
|
17889
|
+
/** A not-yet-finished task's shared headline from its dispatch diagnosis (task-status.ts). */
|
|
17890
|
+
function dispatchHeadline(d) {
|
|
17891
|
+
const read = stageOfDispatch(d);
|
|
17892
|
+
return taskStatusOf({ stage: read.stage, ...(read.need === undefined ? {} : { need: read.need }) }).headline;
|
|
17893
|
+
}
|
|
17215
17894
|
function statusLineHtml(status, extra = "") {
|
|
17216
17895
|
return `<span class="status-line" data-work-status="${escape(status.token)}" data-tone="${escape(status.tone)}"><i class="status-dot" aria-hidden="true"></i><span class="status-label">${escape(status.label)}</span>${extra}</span>`;
|
|
17217
17896
|
}
|
|
@@ -17265,7 +17944,7 @@ function workPage(chrome, data) {
|
|
|
17265
17944
|
const actionHref = row.primaryAction?.code === 'open-result' && target?.runId != null
|
|
17266
17945
|
? `/review?result=${encodeURIComponent(target.taskId)}&run=${target.runId}${row.repo === null ? '' : '&project=' + encodeURIComponent(row.repo)}`
|
|
17267
17946
|
: browserWorkActionHref(row);
|
|
17268
|
-
const needsYouDetail = row.status.views.includes('needs-you') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem;
|
|
17947
|
+
const needsYouDetail = (row.status.views.includes('needs-you') || row.status.label === 'Failed') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.label !== 'Ready for review' && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem;
|
|
17269
17948
|
return {
|
|
17270
17949
|
id: row.rootId, title: row.title, href: taskHref(row.rootId),
|
|
17271
17950
|
project: data.multiProject || row.repo === null ? (row.repo === null ? 'Unplaced' : projectName(row.repo)) : null,
|
|
@@ -17288,7 +17967,7 @@ function workPage(chrome, data) {
|
|
|
17288
17967
|
};
|
|
17289
17968
|
return screen('work', `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${list}${pages}`, { chrome, workspace: { view } });
|
|
17290
17969
|
}
|
|
17291
|
-
function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default") {
|
|
17970
|
+
function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = null, permissionDefault = "auto", qualityDefault = "default", replaced = new Map()) {
|
|
17292
17971
|
const filters = TASK_STATES.map(one => (one === state ? `<strong>${one}</strong>` : `<a href="/tasks?state=${one}">${one}</a>`)).join(" · ");
|
|
17293
17972
|
const rows = tasks.length === 0
|
|
17294
17973
|
? `<p class="meta">${state === null
|
|
@@ -17296,7 +17975,7 @@ function tasksPage(chrome, tasks, state, csrf, problem, repo = null, prefill = n
|
|
|
17296
17975
|
: `Nothing is ${escape(state)}.`}</p>`
|
|
17297
17976
|
: tasks
|
|
17298
17977
|
.map(task => `<a class="row" href="${taskHref(task.id)}"><span class="mono">${escape(task.id)}</span> ` +
|
|
17299
|
-
`${escape(task.title)} <span class="right badge badge-${escape(task.state)}">${escape(task.state)}</span></a>`)
|
|
17978
|
+
`${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>`)
|
|
17300
17979
|
.join("\n");
|
|
17301
17980
|
return screen("tasks", [
|
|
17302
17981
|
"<h1>Tasks</h1>",
|
|
@@ -17967,7 +18646,7 @@ function githubReposPage(chrome, data) {
|
|
|
17967
18646
|
`<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
|
|
17968
18647
|
].join("\n"), { chrome });
|
|
17969
18648
|
}
|
|
17970
|
-
function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn) {
|
|
18649
|
+
function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn, checkLevelOf) {
|
|
17971
18650
|
// The onboarding card (repo onboarding, findings 1-39): preview first,
|
|
17972
18651
|
// then a password-confirmed clone into a configured root. Disabled
|
|
17973
18652
|
// states explain themselves in words (finding 28/39).
|
|
@@ -18058,6 +18737,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
|
|
|
18058
18737
|
name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
|
|
18059
18738
|
knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
|
|
18060
18739
|
...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
|
|
18740
|
+
...(checkLevelOf === undefined ? {} : { checks: { href: `/settings/checks?repo=${encodeURIComponent(path)}`, level: CHECK_LEVEL_WORDS[checkLevelOf(path)] } }),
|
|
18061
18741
|
peek: peek === null ? null : [
|
|
18062
18742
|
...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
|
|
18063
18743
|
...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
|
|
@@ -18208,6 +18888,10 @@ function loginReturn(raw) {
|
|
|
18208
18888
|
const query = parsed.searchParams.toString();
|
|
18209
18889
|
return `${parsed.pathname}${query === "" ? "" : `?${query}`}`;
|
|
18210
18890
|
}
|
|
18891
|
+
/** A request path fit for a log: the one-time sign-in link's code is left out. */
|
|
18892
|
+
export function redactedPath(path) {
|
|
18893
|
+
return path.startsWith(SIGN_IN_LINK_PATH) ? `${SIGN_IN_LINK_PATH}…` : path;
|
|
18894
|
+
}
|
|
18211
18895
|
/** The sign-in page that comes back to `path` afterwards, or plain /login when the path is not one to come back to. */
|
|
18212
18896
|
function loginHref(path) {
|
|
18213
18897
|
const back = loginReturn(path);
|
|
@@ -18331,7 +19015,7 @@ function queueCard(one, csrf, revision, queueRevision, workers, column) {
|
|
|
18331
19015
|
? `<span class="badge">queued</span>`
|
|
18332
19016
|
: `<span class="badge">reserved for ${escape(column)}</span>`;
|
|
18333
19017
|
const chips = ` ${state}` +
|
|
18334
|
-
`${one.dispatch === null || one.dispatch === undefined ? "" : ` <a class="badge" href="${taskHref(one.id)}"
|
|
19018
|
+
`${one.dispatch === null || one.dispatch === undefined ? "" : ` <a class="badge" href="${taskHref(one.id)}" title="${escape(one.dispatch.summary)}">${escape(dispatchHeadline(one.dispatch))}</a>`}`;
|
|
18335
19019
|
const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">` +
|
|
18336
19020
|
`<input type="hidden" name="projectRevision" value="${revision}">` +
|
|
18337
19021
|
`<input type="hidden" name="queueRevision" value="${queueRevision}">` +
|
|
@@ -18802,7 +19486,11 @@ function taskBodyParts(data) {
|
|
|
18802
19486
|
// data-work-status is the shared projection's token (package 1) —
|
|
18803
19487
|
// the same one the Work row, chat, and cockpit carry for this task.
|
|
18804
19488
|
const workToken = resultStatus?.token ?? data.dispatch?.code ?? "unknown";
|
|
18805
|
-
|
|
19489
|
+
// A diagnosis under Task options: the exact technical reason, one tap
|
|
19490
|
+
// away from the shared headline (task-status.ts). Red only when that
|
|
19491
|
+
// headline is Failed.
|
|
19492
|
+
const headline = status.label;
|
|
19493
|
+
const box = (kind, title, detail, code, controls = "") => `<div class="${kind === "problem" && !inReview && headline === "Failed" ? "problem" : "answered"} dispatch-status" id="run-status" data-dispatch-status="${escape(code ?? title.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" data-work-status="${escape(workToken)}">` +
|
|
18806
19494
|
`<div class="dispatch-copy"><strong>${escape(inReview ? "Recorded checks" : title)}</strong><span class="meta">${detail}</span></div>${controls}</div>`;
|
|
18807
19495
|
if (task.state !== "done") {
|
|
18808
19496
|
const diagnosis = data.dispatch ?? null;
|
|
@@ -19487,7 +20175,7 @@ function taskBodyParts(data) {
|
|
|
19487
20175
|
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" }] });
|
|
19488
20176
|
}
|
|
19489
20177
|
facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
|
|
19490
|
-
: 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} ·
|
|
20178
|
+
: 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 }])] }
|
|
19491
20179
|
: { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
|
|
19492
20180
|
facts.push({ label: "Publishes as", parts: [publishesAs] });
|
|
19493
20181
|
// v102: who asked for it, and what the project's approval rules need.
|
|
@@ -19689,7 +20377,13 @@ function taskBodyParts(data) {
|
|
|
19689
20377
|
: ` · filed via ${escape(data.filedVia)}`}${data.deliverable === "report" ? ` · <span class="badge">scout</span>` : ""}</p>`;
|
|
19690
20378
|
// A pull request that can be merged (or opened) owns the one primary action; the result stays in its section.
|
|
19691
20379
|
const pullRequestActs = data.csrf !== "" && (data.pullRequest?.view?.canMerge === true || (data.pullRequest?.view == null && data.pullRequest?.offer != null));
|
|
19692
|
-
const
|
|
20380
|
+
const pullRequestFact = data.pullRequest?.fact != null && data.pullRequest.view?.runId === data.assignment?.receipt?.runId ? data.pullRequest.fact : undefined;
|
|
20381
|
+
const assignmentOptions = data.assignment == null ? null : { workStatus: status, hideAction: (approveForm !== "" && data.dispatch?.action === "approve-scope") || pullRequestActs, problem: status.tone === "problem",
|
|
20382
|
+
planning: data.dispatch?.code === "running" && data.dispatch.role === "planner", ...(pullRequestFact === undefined ? {} : { pullRequest: pullRequestFact }),
|
|
20383
|
+
diagnostics: [...(status.diagnostics ?? []), ...(status.tone === "problem" && status.detail !== data.assignment.detail ? [status] : [])] };
|
|
20384
|
+
// Say it once: a pull request that couldn't open, or merged, is the status
|
|
20385
|
+
// card's own row (its reason or merge commit under Details), not a second card.
|
|
20386
|
+
const pullRequestCard = data.pullRequest == null || (pullRequestFact !== undefined && (data.pullRequest.view?.state === "failed" || data.pullRequest.view?.state === "merged")) ? "" : pullRequestCardHtml(data.pullRequest, data.csrf);
|
|
19693
20387
|
// The result takes over from the task status as soon as it is ready.
|
|
19694
20388
|
const statusHtml = (data.assignment != null && assignmentOptions !== null ? assignmentSummaryHtml(data.assignment, assignmentOptions) : receiptLeads ? completionReceiptCard(data.completion.receipt, task.id, "task", status) : taskStatusCard(status, task.id, data.dispatch ?? null, liveRunId, approveForm !== "" && data.dispatch?.action === "approve-scope")) + checkProgressHtml(data.checkProgress ?? null);
|
|
19695
20389
|
// The exact-run control (v52), directly under the scheduler's answer:
|
|
@@ -19799,6 +20493,135 @@ function taskBodyParts(data) {
|
|
|
19799
20493
|
cancelAct,
|
|
19800
20494
|
`</div><aside class="task-rail">${rail}</aside></div>`,
|
|
19801
20495
|
].join("\n");
|
|
20496
|
+
// Console v2: one thread in time order (the plan, the agent's notes and
|
|
20497
|
+
// results, its questions, the person's replies) and the metadata grouped
|
|
20498
|
+
// for a Details panel. Every form below keeps its own route and fields.
|
|
20499
|
+
const familyRuns = data.family?.runs ?? data.runs.map(run => ({ ...run, taskId: task.id }));
|
|
20500
|
+
const versionLabelOf = (taskId) => {
|
|
20501
|
+
const index = data.family?.versions.findIndex(one => one.id === taskId) ?? -1;
|
|
20502
|
+
return index <= 0 ? "" : ` · revision ${index}`;
|
|
20503
|
+
};
|
|
20504
|
+
const thread = [];
|
|
20505
|
+
const filedBy = data.filer?.name ?? data.coordinator?.label ?? null;
|
|
20506
|
+
const rootFiled = data.family?.root.createdAt ?? task.createdAt;
|
|
20507
|
+
thread.push({ key: "filed", at: rootFiled, kind: "filed", who: "person", author: filedBy ?? "", title: "Filed the task",
|
|
20508
|
+
text: (() => { const goal = data.family?.root.goal ?? scope?.goal ?? null; return goal === null ? null : oneLineOf(goal, 280); })(), link: null, html: "", more: null });
|
|
20509
|
+
const newestBuilt = familyRuns.filter(run => run.outcome === "built" && run.role !== "planner" && run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
|
|
20510
|
+
for (const run of familyRuns) {
|
|
20511
|
+
const live = run.id === liveHistoryRunId || (run.outcome === null && run.id === liveRunId);
|
|
20512
|
+
const noun = runNoun(run);
|
|
20513
|
+
const href = { label: `${noun[0].toUpperCase()}${noun.slice(1)} #${run.id}`, href: `/r/${run.id}` };
|
|
20514
|
+
const revision = versionLabelOf(run.taskId);
|
|
20515
|
+
if (run.role === "planner") {
|
|
20516
|
+
thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "plan", who: "agent", author: run.runner,
|
|
20517
|
+
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)}`,
|
|
20518
|
+
text: null, link: href, html: "", more: null });
|
|
20519
|
+
continue;
|
|
20520
|
+
}
|
|
20521
|
+
if (live) {
|
|
20522
|
+
thread.push({ key: `run-${run.id}`, at: run.startedAt, kind: "progress", who: "agent", author: run.runner,
|
|
20523
|
+
title: `${run.role === "reviewer" ? "Reviewing" : run.role === "scout" ? "Investigating" : "Building"}${revision}`,
|
|
20524
|
+
text: `${homePhaseWords(run)}.`, link: href, html: "", more: null });
|
|
20525
|
+
continue;
|
|
20526
|
+
}
|
|
20527
|
+
const title = run.role === "reviewer" ? "Reviewed the result"
|
|
20528
|
+
: run.outcome === "built" ? (run.role === "scout" ? "Report ready" : "Result ready")
|
|
20529
|
+
: run.outcome === "no-change" ? "Finished with no change"
|
|
20530
|
+
: run.outcome === "parked" ? "Stopped to ask"
|
|
20531
|
+
: run.outcome === null ? "Attempt never finished"
|
|
20532
|
+
: `${noun[0].toUpperCase()}${noun.slice(1)} ${run.outcome}`;
|
|
20533
|
+
thread.push({ key: `run-${run.id}`, at: run.finishedAt ?? run.startedAt, kind: "result", who: "agent", author: run.runner,
|
|
20534
|
+
title: `${title}${revision}`, text: run.handoff === null ? (run.reason === null || run.outcome === "built" ? null : reasonWords(run.reason)) : oneLineOf(run.handoff, 600),
|
|
20535
|
+
link: href, html: "",
|
|
20536
|
+
more: previousResult !== null && run.id === newestBuilt?.id && run.taskId === task.id ? { summary: "Result details", html: previousResult.html } : null });
|
|
20537
|
+
}
|
|
20538
|
+
// What the agent is working through now, and how its plan changed.
|
|
20539
|
+
const liveAt = liveRun?.startedAt ?? task.updatedAt;
|
|
20540
|
+
if (planCard !== "")
|
|
20541
|
+
thread.push({ key: "plan", at: familyRuns.filter(run => run.role === "planner").map(run => run.finishedAt ?? run.startedAt).sort().at(-1) ?? task.updatedAt,
|
|
20542
|
+
kind: "plan", who: "agent", author: "", title: "The plan", text: null, link: null, html: planCard, more: null });
|
|
20543
|
+
if (progressCard !== "")
|
|
20544
|
+
thread.push({ key: "progress", at: liveAt, kind: "progress", who: "agent", author: "", title: "Progress", text: null, link: null, html: progressCard, more: null });
|
|
20545
|
+
if (revisionLedgerCard !== "")
|
|
20546
|
+
thread.push({ key: "plan-revisions", at: liveAt, kind: "progress", who: "agent", author: "", title: "Plan changes", text: null, link: null, html: revisionLedgerCard, more: null });
|
|
20547
|
+
// The change a person asked for: the revision's own brief.
|
|
20548
|
+
if (data.revision != null && !("problem" in data.revision)) {
|
|
20549
|
+
const feedback = data.revision;
|
|
20550
|
+
thread.push({ key: "revision", at: task.createdAt, kind: "reply", who: "person", author: feedback.comments[0]?.author ?? "You",
|
|
20551
|
+
title: feedback.kind === "ci-repair" ? "Asked for a CI repair" : feedback.kind === "criterion-repair" ? "Asked for a repair" : "Asked for changes",
|
|
20552
|
+
text: feedback.comments.map(one => `${one.path === null ? "" : `${one.path}${one.line === null ? "" : `:${one.line}`} — `}${one.note}`).join("\n"),
|
|
20553
|
+
link: { label: `From build #${feedback.sourceRun}`, href: `/r/${feedback.sourceRun}` }, html: "", more: null });
|
|
20554
|
+
}
|
|
20555
|
+
for (const decision of data.decisions) {
|
|
20556
|
+
if (decision.state === "open" || decision.state === "expired")
|
|
20557
|
+
continue;
|
|
20558
|
+
thread.push({ key: `question-${decision.id}`, at: decision.createdAt, kind: "question", who: "agent", author: "", title: "Asked",
|
|
20559
|
+
text: decision.question, link: { label: "Question", href: `/d/${decision.id}` }, html: "", more: null });
|
|
20560
|
+
if (decision.answeredAt !== null) {
|
|
20561
|
+
const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice;
|
|
20562
|
+
thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt, kind: "reply", who: "person", author: decision.answeredBy ?? "You", title: "Answered",
|
|
20563
|
+
text: [chosen, decision.note].filter((one) => one !== null && one !== "").join(" — ") || null, link: null, html: "", more: null });
|
|
20564
|
+
}
|
|
20565
|
+
}
|
|
20566
|
+
// Open questions keep their answer card (and its #task-questions anchor).
|
|
20567
|
+
if (decisionRail !== "")
|
|
20568
|
+
thread.push({ key: "questions", at: openDecisions.map(one => one.createdAt).sort()[0] ?? task.updatedAt, kind: "question", who: "agent", author: "",
|
|
20569
|
+
title: openDecisions.length === 1 ? "Asked you a question" : `Asked you ${openDecisions.length} questions`, text: null, link: null, html: decisionRail, more: null });
|
|
20570
|
+
for (const note of data.steering ?? []) {
|
|
20571
|
+
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 });
|
|
20572
|
+
}
|
|
20573
|
+
thread.sort((a, b) => a.at === b.at ? 0 : a.at < b.at ? -1 : 1);
|
|
20574
|
+
// The Details panel: the same facts, grouped and said once.
|
|
20575
|
+
const lastRunForAgent = liveRun ?? familyRuns.filter(run => run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
|
|
20576
|
+
const work = [];
|
|
20577
|
+
work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
|
|
20578
|
+
if (data.repo !== null)
|
|
20579
|
+
work.push({ label: "Project", parts: [projectName(data.repo)] });
|
|
20580
|
+
work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
|
|
20581
|
+
: data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
|
|
20582
|
+
if (scope !== null)
|
|
20583
|
+
work.push({ label: "Checks level", parts: [qualityModeTitle(scope.qualityMode ?? "default")] });
|
|
20584
|
+
for (const label of ["Worker", "Last attempt", "Queue", "Budget", "Policy", "Strikes"]) {
|
|
20585
|
+
const fact = facts.find(one => one.label === label);
|
|
20586
|
+
if (fact !== undefined)
|
|
20587
|
+
work.push(fact);
|
|
20588
|
+
}
|
|
20589
|
+
const links = [];
|
|
20590
|
+
if (data.family != null && data.family.root.id !== task.id)
|
|
20591
|
+
links.push({ label: "Parent", parts: [{ label: data.family.root.title, href: `${taskHref(data.family.root.id)}?version=${encodeURIComponent(data.family.root.id)}` }] });
|
|
20592
|
+
const blockers = data.waitsFor ?? [];
|
|
20593
|
+
if (blockers.length > 0)
|
|
20594
|
+
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]) });
|
|
20595
|
+
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);
|
|
20596
|
+
if (laterVersions.length > 0)
|
|
20597
|
+
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)}` }]) });
|
|
20598
|
+
const published = facts.find(one => one.label === "Published");
|
|
20599
|
+
if (published !== undefined)
|
|
20600
|
+
links.push({ ...published, label: "Pull request" });
|
|
20601
|
+
else if (data.pullRequest?.view != null) {
|
|
20602
|
+
const pr = data.pullRequest.view;
|
|
20603
|
+
const prHref = safePrUrl(pr.prUrl);
|
|
20604
|
+
links.push({ label: "Pull request", parts: [prHref === null ? `#${pr.prNumber ?? "?"}` : { label: `#${pr.prNumber ?? "?"}`, href: prHref }, ` · ${pr.label}`] });
|
|
20605
|
+
}
|
|
20606
|
+
const review = [];
|
|
20607
|
+
const approvedScope = facts.find(one => one.label === "Approved scope" || one.label === "Scope");
|
|
20608
|
+
if (approvedScope !== undefined)
|
|
20609
|
+
review.push({ ...approvedScope, label: approvedScope.label === "Approved scope" ? "Approvals" : "Scope" });
|
|
20610
|
+
review.push({ label: "Who reviews", parts: [facts.find(one => one.label === "Approval rules")?.parts[0] ?? "Any approver in this project"] });
|
|
20611
|
+
review.push({ label: "Publishes as", parts: [publishesAs] });
|
|
20612
|
+
const about = [];
|
|
20613
|
+
const filedFact = facts.find(one => one.label === "Filed by");
|
|
20614
|
+
about.push(filedFact ?? { label: "Filed by", parts: [filedBy ?? (data.filedVia == null ? "Not recorded" : `via ${data.filedVia}`)] });
|
|
20615
|
+
about.push({ label: "Filed", parts: [{ at: rootFiled }] });
|
|
20616
|
+
about.push({ label: "Updated", parts: [{ at: task.updatedAt }] });
|
|
20617
|
+
for (const label of ["Audit", "This attempt", "Task total"]) {
|
|
20618
|
+
const fact = facts.find(one => one.label === label);
|
|
20619
|
+
if (fact !== undefined)
|
|
20620
|
+
about.push({ ...fact, label: label === "Audit" ? "Audit" : label === "This attempt" ? "Usage, this attempt" : "Usage, all attempts" });
|
|
20621
|
+
}
|
|
20622
|
+
const details = [
|
|
20623
|
+
{ title: "Work", facts: work }, { title: "Links", facts: links }, { title: "Review", facts: review }, { title: "About", facts: about },
|
|
20624
|
+
];
|
|
19802
20625
|
// The rebuilt page (shadcn/ui): the same parts in a calmer order — what
|
|
19803
20626
|
// needs a person, then facts, then folds; the mechanics under Manage.
|
|
19804
20627
|
const MANAGE = new Set(["steering", "waits-for", "holds"]);
|
|
@@ -19817,25 +20640,35 @@ function taskBodyParts(data) {
|
|
|
19817
20640
|
status: data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions) : null,
|
|
19818
20641
|
statusHtml,
|
|
19819
20642
|
approval: approvalHtml,
|
|
20643
|
+
// The plan, progress and plan changes are thread entries now; the rest still needs a person here.
|
|
19820
20644
|
lead: [
|
|
19821
20645
|
{ key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
|
|
19822
|
-
{ key: "pull-request", html:
|
|
19823
|
-
{ key: "needs-scope", html: needsScopeCard },
|
|
19824
|
-
{ key: "
|
|
20646
|
+
{ key: "pull-request", html: pullRequestCard },
|
|
20647
|
+
{ key: "needs-scope", html: needsScopeCard },
|
|
20648
|
+
{ key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
|
|
19825
20649
|
].filter(one => one.html !== ""),
|
|
19826
|
-
questions
|
|
20650
|
+
// Open questions are answered in the thread.
|
|
20651
|
+
questions: "",
|
|
19827
20652
|
facts,
|
|
19828
20653
|
sections: [
|
|
19829
|
-
...(previousResult === null ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
|
|
19830
|
-
//
|
|
19831
|
-
...sectionParts.filter(one => !MANAGE.has(one.id)).map(one => one.id === "scope" &&
|
|
20654
|
+
...(previousResult === null || thread.some(one => one.more !== null) ? [] : [{ id: "result", title: previousResult.title, html: previousResult.html, open: false, count: null }]),
|
|
20655
|
+
// Questions are in the thread; the scope and the ledgers fold here until asked.
|
|
20656
|
+
...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),
|
|
19832
20657
|
],
|
|
19833
20658
|
manage: [
|
|
19834
20659
|
...sectionParts.filter(one => MANAGE.has(one.id)),
|
|
19835
20660
|
{ id: "task-diagnostics", title: "Task options", html: optionsHtml, open: optionsOpen, count: null },
|
|
19836
20661
|
],
|
|
19837
20662
|
cancel: cancelForm === "" ? null : { html: cancelForm, open: data.cancelDraft !== undefined },
|
|
20663
|
+
journey: data.guide !== true || task.state === "cancelled" || data.status === undefined ? null : (() => {
|
|
20664
|
+
const planning = data.plan === "requested" && approveForm === "";
|
|
20665
|
+
const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
|
|
20666
|
+
return firstTaskJourney(read, scope !== null && approvalOf(scope).approved, task.state === "done");
|
|
20667
|
+
})(),
|
|
19838
20668
|
// "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
|
|
20669
|
+
thread,
|
|
20670
|
+
details,
|
|
20671
|
+
chatHref: data.csrf === "" ? null : taskChatHref(data.rootId ?? task.id),
|
|
19839
20672
|
everyTime: data.repo === null || data.csrf === "" ? null : (() => {
|
|
19840
20673
|
const starter = starterForWork(`${task.title}\n${scope?.goal ?? ""}`);
|
|
19841
20674
|
return { href: `/settings/flows?repo=${encodeURIComponent(data.repo)}&starter=${starter.id}#starter-${starter.id}`, starter: starter.name };
|
|
@@ -20071,11 +20904,11 @@ function cockpitStatusOf(view) {
|
|
|
20071
20904
|
// The receipt's own status (package 3): the same projection the chat
|
|
20072
20905
|
// receipt and the run page print, read from the shared detail.
|
|
20073
20906
|
if (view.run === null || view.detail === null)
|
|
20074
|
-
return view.assignment === null ? resultStatusOf(null, null) : assignmentPresentationOf(view.assignment).status;
|
|
20907
|
+
return view.assignment === null ? resultHeadlineOf(resultStatusOf(null, null)) : assignmentPresentationOf(view.assignment).status;
|
|
20075
20908
|
const receiptStatus = receiptStatusOf(view.detail.receipt);
|
|
20076
20909
|
if (view.assignment !== null)
|
|
20077
20910
|
return assignmentStatusOf(assignmentWithEvidence(view.assignment, receiptStatus, view.run.id));
|
|
20078
|
-
return receiptStatus;
|
|
20911
|
+
return resultHeadlineOf(receiptStatus);
|
|
20079
20912
|
}
|
|
20080
20913
|
/** Turn verifier records into one sentence a project owner can act on.
|
|
20081
20914
|
* A command-not-found result means the check could not start, not that the
|
|
@@ -20368,7 +21201,8 @@ function pullRequestCardHtml(pr, csrf) {
|
|
|
20368
21201
|
const view = pr.view;
|
|
20369
21202
|
const url = safePrUrl(view.prUrl);
|
|
20370
21203
|
const link = view.prNumber === null ? "" : url === null ? `<span class="mono">PR #${view.prNumber}</span>` : `<a href="${escape(url)}" class="mono" rel="noreferrer" target="_blank">PR #${view.prNumber}</a>`;
|
|
20371
|
-
|
|
21204
|
+
// A pull request's trouble never undoes the result: amber, never red.
|
|
21205
|
+
const tone = view.state === "ready" || view.state === "merged" ? "ok" : view.state === "failing" || view.state === "failed" ? "problem" : "waiting";
|
|
20372
21206
|
const revision = view.revisionTask === null || view.state !== "failing" ? "" : ` <a href="${taskHref(view.revisionTask)}">Open revision</a>`;
|
|
20373
21207
|
const merged = view.mergeCommit === null ? "" : `<p class="meta">Merge commit <span class="mono">${escape(view.mergeCommit.slice(0, 12))}</span></p>`;
|
|
20374
21208
|
const target = pr.target === null ? "the base branch" : pr.target;
|
|
@@ -20377,7 +21211,7 @@ function pullRequestCardHtml(pr, csrf) {
|
|
|
20377
21211
|
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="run" value="${view.runId}">` +
|
|
20378
21212
|
`<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>` +
|
|
20379
21213
|
`<label>Password<input type="password" name="token" autocomplete="current-password" required></label>` +
|
|
20380
|
-
`<button type="submit">Merge</button
|
|
21214
|
+
(view.fullCheck === null ? `<button type="submit">Merge</button>` : `<input type="hidden" name="anyway" value="1"><button type="submit" class="secondary">Merge anyway</button>`) + `</form>`;
|
|
20381
21215
|
return `<section class="card pull-request pull-request--${tone}" id="merge" data-pull-request="${escape(view.state)}">` +
|
|
20382
21216
|
`<div class="pull-request-head"><strong>${escape(view.label)}</strong>${link}</div>` +
|
|
20383
21217
|
`<p class="meta">${escape(view.detail)}${revision}</p>${merged}${act}</section>`;
|
|
@@ -20420,6 +21254,18 @@ const PHASE_WORDS = {
|
|
|
20420
21254
|
"capturing-evidence": "capturing evidence",
|
|
20421
21255
|
committing: "committing",
|
|
20422
21256
|
};
|
|
21257
|
+
/** An agent's phase on the home card, from the machine's own vocabulary. */
|
|
21258
|
+
function homePhaseWords(run) {
|
|
21259
|
+
if (run.role === "planner")
|
|
21260
|
+
return "Planning";
|
|
21261
|
+
if (run.role === "reviewer")
|
|
21262
|
+
return "Reviewing";
|
|
21263
|
+
if (run.role === "scout")
|
|
21264
|
+
return "Investigating";
|
|
21265
|
+
const words = { "agent-running": "Writing the change", "validating-handoff": "Checking its handoff", "capturing-evidence": "Saving its evidence",
|
|
21266
|
+
committing: "Committing", "verifying-proof": "Running the checks", "correcting-proof": "Correcting its evidence" };
|
|
21267
|
+
return run.phase === null ? "Starting" : words[run.phase] ?? "Working";
|
|
21268
|
+
}
|
|
20423
21269
|
function phaseWords(phase) {
|
|
20424
21270
|
return PHASE_WORDS[phase] ?? "the agent is working";
|
|
20425
21271
|
}
|
|
@@ -21076,6 +21922,34 @@ function evidenceBundleCard(view, run, publication = null, review = null) {
|
|
|
21076
21922
|
* the operator's acceptance alone — shared with the review cockpit's
|
|
21077
21923
|
* header chip so the two surfaces never name one state differently. */
|
|
21078
21924
|
/** The receipt's facts as the shared projection reads them. */
|
|
21925
|
+
/** A result's follow-ups: checks run on its commit since it finished, and its tests task. */
|
|
21926
|
+
function followUpsFor(store, evidenceRoot, run, now) {
|
|
21927
|
+
if (run.role !== "builder" || run.finishedAt === null || run.headRevision === null)
|
|
21928
|
+
return null;
|
|
21929
|
+
const repo = store.refById(run.taskRef)?.repo ?? null;
|
|
21930
|
+
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);
|
|
21931
|
+
return { repo, checks: followUpChecksOf(store, run.id, now, evidenceRoot), testsTask: tests === undefined ? null : String(tests["outcome"]),
|
|
21932
|
+
quick: repo !== null && liveQuickCommand(store, repo) !== null, full: repo !== null && store.liveVerifyCommand(repo) !== null };
|
|
21933
|
+
}
|
|
21934
|
+
const FOLLOW_UP_WORDS = { waiting: "waiting for a worker", running: "running", passed: "passed", failed: "failed", "not-run": "didn't run" };
|
|
21935
|
+
/** Run checks and Add tests, under the result's Checks: what ran since, then the two acts. */
|
|
21936
|
+
function followUpsHtml(followUps, runId, o) {
|
|
21937
|
+
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])}` +
|
|
21938
|
+
`${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape(one.head.slice(0, 7))}</span>` +
|
|
21939
|
+
`<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
|
|
21940
|
+
`${one.logArtifactId === null ? "" : ` <a href="/r/${runId}/evidence/${one.logArtifactId}">Log</a>`}</li>`).join("");
|
|
21941
|
+
const hidden = `<input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">`;
|
|
21942
|
+
const canCheck = followUps.quick || followUps.full;
|
|
21943
|
+
const checkActs = !canCheck || o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/checks" class="follow-up-act">${hidden}` +
|
|
21944
|
+
(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>`
|
|
21945
|
+
: `<button type="submit" name="level" value="${followUps.quick ? "quick" : "full"}">Run checks</button>`) + `</form>`;
|
|
21946
|
+
const tests = followUps.testsTask !== null ? `<p class="meta">Tests task: <a href="${taskHref(followUps.testsTask)}">${escape(followUps.testsTask)}</a></p>`
|
|
21947
|
+
: o.csrf === "" ? "" : `<form method="post" action="/r/${runId}/add-tests" class="follow-up-act">${hidden}<button type="submit" class="secondary">Add tests</button></form>`;
|
|
21948
|
+
return `<section class="result-section follow-ups" id="follow-ups" data-follow-ups><strong>Follow-ups</strong>` +
|
|
21949
|
+
(rows === "" ? "" : `<ul class="follow-up-list">${rows}</ul>`) +
|
|
21950
|
+
(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>`) +
|
|
21951
|
+
(checkActs === "" && tests === "" ? "" : `<div class="follow-up-acts">${checkActs}${tests}</div>`) + `</section>`;
|
|
21952
|
+
}
|
|
21079
21953
|
function receiptStatusOf(view, review = view.review) {
|
|
21080
21954
|
// The evidence health rides the status (repair 2026-09-14): a result
|
|
21081
21955
|
// whose stored records are damaged is never called ready on the strength
|
|
@@ -21144,7 +22018,8 @@ function completionReceiptCard(view, taskId, place, standing = receiptStatusOf(v
|
|
|
21144
22018
|
lead +
|
|
21145
22019
|
`<p class="receipt-summary">${escape(conciseOutcomeOf(view.summary ?? (view.outcome === "no-change" ? "The agent found that no repository change was needed." : "The build finished without a concise handoff.")))}</p>` +
|
|
21146
22020
|
(headline ? `<div class="receipt-actions"><a class="button-link" href="${escape(resultHref)}" data-open-result data-primary-action>${escape(status.action?.label ?? "Open result")}</a></div>` : "") +
|
|
21147
|
-
|
|
22021
|
+
// A pull request that couldn't open never undoes the result: an amber note, not red.
|
|
22022
|
+
(view.publication?.state === "failed" ? `<p class="receipt-note">${statusIconSvg("note")} Pull request couldn't open. The commit is safe locally.</p>` : "") +
|
|
21148
22023
|
caveats +
|
|
21149
22024
|
coverage +
|
|
21150
22025
|
`<details class="receipt-details"><summary>Result details</summary>` +
|
|
@@ -21195,16 +22070,20 @@ function resultPanelParts(detail, o) {
|
|
|
21195
22070
|
const { run, receipt, proof, terminal, handoff } = detail;
|
|
21196
22071
|
const facts = receipt.facts;
|
|
21197
22072
|
const current = detail.assignment?.receipt?.runId === run.id ? detail.assignment : null;
|
|
21198
|
-
const
|
|
21199
|
-
|
|
22073
|
+
const followUps = detail.followUps ?? null;
|
|
22074
|
+
const resultLinks = { result: o.hrefFor("summary"), checks: o.hrefFor("checks"), pullRequest: `${taskHref(detail.rootId ?? detail.taskId)}#merge`,
|
|
22075
|
+
...(followUps !== null && (followUps.quick || followUps.full) && o.csrf !== "" ? { runChecks: `${o.hrefFor("checks")}#follow-ups` } : {}) };
|
|
22076
|
+
const presentation = current == null ? null : assignmentPresentationOf(current, { additionalAttention: [...facts.evidenceProblems, ...receipt.caveats],
|
|
22077
|
+
...(detail.publication === null ? {} : { pullRequest: pullRequestFactOf({ ...detail.publication, lastCheckState: detail.ciFailing ? "failing" : detail.publication.lastCheckState }) }),
|
|
22078
|
+
evidence: { damaged: facts.evidenceHealth.damaged, missing: facts.evidenceHealth.missing, shortened: facts.evidenceHealth.shortened }, links: resultLinks });
|
|
22079
|
+
const taskStatus = presentation?.taskStatus ?? null;
|
|
22080
|
+
const status = presentation?.status ?? resultHeadlineOf(receiptStatusOf(receipt));
|
|
21200
22081
|
const stored = receiptStatusOf(receipt, null);
|
|
21201
22082
|
const humanReview = manualReviewOnly(proof === null ? null : { ...proof, verdict: proof.verdict ?? "" });
|
|
21202
22083
|
const directAssessment = proof?.matrix.some(row => row.assessment !== undefined) === true;
|
|
21203
22084
|
const awaitingGoalReview = directAssessment && proof?.reasons.length === 1 && proof.reasons[0] === GOAL_ASSESSMENT_PENDING;
|
|
21204
22085
|
const assessmentReasons = directAssessment ? new Set(proof.matrix.flatMap(row => row.review ? [`${row.review.author} ${row.review.judgement === "contradicts" ? "contradicts" : "needs more evidence for"} criterion "${row.id}": ${row.review.note}`] : [])) : new Set();
|
|
21205
22086
|
const physicalReasons = receipt.reasons.filter(reason => !assessmentReasons.has(reason));
|
|
21206
|
-
if (directAssessment && status.token === "verification-needed" && proof?.verdict === "short" && proof.matrix.some(row => row.review?.judgement === "cannot-tell") && physicalReasons.length === 0)
|
|
21207
|
-
status = { ...status, label: "More evidence needed", tone: "attention" };
|
|
21208
22087
|
const runId = run.id;
|
|
21209
22088
|
const shots = proof?.screenshots ?? [];
|
|
21210
22089
|
const shown = shots.filter(one => one.problem === null);
|
|
@@ -21238,10 +22117,11 @@ function resultPanelParts(detail, o) {
|
|
|
21238
22117
|
attention.push(...(handoff?.followUps ?? []).map(one => `Follow-up: ${one}`));
|
|
21239
22118
|
// Publication risks stay in the open (repair 2026-09-14); the routine
|
|
21240
22119
|
// publication fact lives with the build details below.
|
|
21241
|
-
|
|
22120
|
+
// With the shared status, the pull request is its own quiet row (its reason under Details).
|
|
22121
|
+
if (publication !== null && publication.state === "failed" && taskStatus === null) {
|
|
21242
22122
|
attention.push(`Publication failed after ${publication.attempts} attempt${publication.attempts === 1 ? "" : "s"}${publication.lastError === null ? "" : ` — ${oneLineOf(publication.lastError, 200)}`}. No pull request or merge is recorded.`);
|
|
21243
22123
|
}
|
|
21244
|
-
if (detail.ciFailing && publication !== null)
|
|
22124
|
+
if (detail.ciFailing && publication !== null && taskStatus === null)
|
|
21245
22125
|
attention.push(`CI is failing on PR #${publication.prNumber} at the last check.`);
|
|
21246
22126
|
// Requirements only a person can confirm: plain words and one Accept,
|
|
21247
22127
|
// in neutral ink — nothing failed. Accepting records the person's
|
|
@@ -21433,7 +22313,7 @@ function resultPanelParts(detail, o) {
|
|
|
21433
22313
|
}
|
|
21434
22314
|
if (proof.proofProblem !== null)
|
|
21435
22315
|
checkParts.push(`<p class="problem">Verification details are unavailable: ${escape(proof.proofProblem)}</p>`);
|
|
21436
|
-
checkParts.push(proof.checkLog === null ? `<p class="meta" data-cockpit-source="machine"
|
|
22316
|
+
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"'));
|
|
21437
22317
|
checkParts.push(proof.proof === null || proof.proof.checks.length === 0
|
|
21438
22318
|
? directAssessment ? "" : `<p class="meta" data-cockpit-source="agent">The agent reported no checks.</p>`
|
|
21439
22319
|
: `<details class="cockpit-proof-group" data-cockpit-source="agent"><summary>Agent checks · ${proof.proof.checks.length}</summary><div class="result-section"><ul>` +
|
|
@@ -21451,6 +22331,8 @@ function resultPanelParts(detail, o) {
|
|
|
21451
22331
|
? directAssessment ? "" : `<p class="meta" data-cockpit-source="caveats">No caveats were reported.</p>`
|
|
21452
22332
|
: `<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>`);
|
|
21453
22333
|
}
|
|
22334
|
+
if (followUps !== null && run.role === "builder")
|
|
22335
|
+
checkParts.push(followUpsHtml(followUps, runId, o));
|
|
21454
22336
|
// The handoff's own account of its checks — the agent's words, labeled
|
|
21455
22337
|
// as such, whether or not a proof exists.
|
|
21456
22338
|
if (!directAssessment && handoff !== null && handoff.verification.length > 0)
|
|
@@ -21517,11 +22399,13 @@ function resultPanelParts(detail, o) {
|
|
|
21517
22399
|
const html = (`<section class="card result-panel" id="result" data-result-panel data-result-place="${o.place}" data-result-lead="${lead}" data-result-task="${escape(detail.rootId ?? detail.taskId)}" data-result-user="${escape(o.user)}"${resultFactsAttributes(facts)}>` +
|
|
21518
22400
|
(o.back === null ? "" : `<p class="result-back"><a href="${escape(o.back.href)}" data-result-back>← ${escape(o.back.label)}</a></p>`) +
|
|
21519
22401
|
(detail.history ?? "") +
|
|
21520
|
-
|
|
22402
|
+
// One headline: with the shared status, its words lead (task-status.ts); "Changes saved" would compete with them.
|
|
22403
|
+
(taskStatus !== null && o.headStatus !== false
|
|
22404
|
+
? `<header class="result-head"><div data-headline-tone="${taskStatus.tone}"><h2 class="status-headline" data-work-status="${escape(status.token)}"><i aria-hidden="true"></i>${escape(taskStatus.headline)}</h2><p class="status-sentence">${escape(taskStatus.sentence)}</p></div></header>`
|
|
22405
|
+
: `<header class="result-head"><div><h2>${escape(heading)}</h2></div>${o.headStatus === false ? "" : statusLineHtml(status)}</header>`) +
|
|
21521
22406
|
`<p class="result-summary">${escape(outcome)}</p>` +
|
|
21522
22407
|
(current == null || o.place === "review" && (current.state === "ready-to-check" || current.state === "complete") ? "" :
|
|
21523
|
-
`<div class="verdict" data-current-outcome>${
|
|
21524
|
-
(current.receipt?.checks.status === "failed" || current.receipt?.checks.status === "unavailable" ? `<p class="problem">${escape(current.detail)}</p>` : "") + `</div>`) +
|
|
22408
|
+
`<div class="verdict" data-current-outcome data-headline="${escape(taskStatus.headline)}">${statusDetailsHtml(taskStatus)}${statusWhyHtml(taskStatus)}</div>`) +
|
|
21525
22409
|
attentionHtml +
|
|
21526
22410
|
youCheckHtml +
|
|
21527
22411
|
action +
|
|
@@ -21536,7 +22420,7 @@ function resultPanelParts(detail, o) {
|
|
|
21536
22420
|
const panel = {
|
|
21537
22421
|
attributes: { "data-result-panel": "", "data-result-place": o.place, "data-result-lead": lead, "data-result-task": detail.rootId ?? detail.taskId, "data-result-user": o.user, ...resultFactsAttributeMap(facts) },
|
|
21538
22422
|
heading, outcome,
|
|
21539
|
-
|
|
22423
|
+
status: taskStatus,
|
|
21540
22424
|
reviewHistory: REVIEW_TOKENS.has(status.token) ? status.detail : null,
|
|
21541
22425
|
attention: attention.filter(one => !shortenedMaterialReason(one)).map(plainReasonWords),
|
|
21542
22426
|
youCheck,
|
|
@@ -21551,40 +22435,6 @@ function resultPanelParts(detail, o) {
|
|
|
21551
22435
|
};
|
|
21552
22436
|
return { html, panel };
|
|
21553
22437
|
}
|
|
21554
|
-
const CHIP_ICONS = { check: `<path d="M20 6 9 17l-5-5"/>`, x: `<path d="M18 6 6 18M6 6l12 12"/>` };
|
|
21555
|
-
/** The chips as data: the server list below and the rebuilt result page. */
|
|
21556
|
-
function verdictChipsOf(current, facts) {
|
|
21557
|
-
const chips = [];
|
|
21558
|
-
const checks = current.receipt?.checks.status ?? null;
|
|
21559
|
-
if (checks === "passed")
|
|
21560
|
-
chips.push({ tone: "success", label: "Checks passed", icon: "check", title: null });
|
|
21561
|
-
else if (checks === "failed")
|
|
21562
|
-
chips.push({ tone: "danger", label: "Checks failed", icon: "x", title: null });
|
|
21563
|
-
else if (checks === "unavailable")
|
|
21564
|
-
chips.push({ tone: "warning", label: "Check unavailable", icon: null, title: null });
|
|
21565
|
-
const publication = { none: ["neutral", "Branch only"], intended: ["info", "Publishing"], pushed: ["info", "Pushed"], opened: ["info", "Pull request open"], failed: ["danger", "Publish failed"] };
|
|
21566
|
-
const [tone, label] = publication[facts.publicationState] ?? ["neutral", facts.publicationState];
|
|
21567
|
-
chips.push({ tone, label, icon: null, title: facts.publicationWords });
|
|
21568
|
-
const health = facts.evidenceHealth;
|
|
21569
|
-
if (health.damaged > 0)
|
|
21570
|
-
chips.push({ tone: "danger", label: "Damaged evidence", icon: null, title: null });
|
|
21571
|
-
else if (health.missing > 0)
|
|
21572
|
-
chips.push({ tone: "warning", label: "Missing evidence", icon: null, title: null });
|
|
21573
|
-
else if (health.shortened > 0)
|
|
21574
|
-
chips.push({ tone: "warning", label: "Partial output", icon: null, title: "Some saved output was shortened when stored" });
|
|
21575
|
-
return { chips, by: current.completion === null ? null : current.completion.actor.replace(/^(?:operator|coordinator|lead):/, "") };
|
|
21576
|
-
}
|
|
21577
|
-
/** The verdict at a glance: checks, where the work lives, and whether any
|
|
21578
|
-
* saved output is partial, as chips instead of sentences. The same facts
|
|
21579
|
-
* stay available in words on the Checks tab and in Build details. */
|
|
21580
|
-
function verdictChipsHtml(current, facts) {
|
|
21581
|
-
const { chips, by } = verdictChipsOf(current, facts);
|
|
21582
|
-
const items = chips.map(one => {
|
|
21583
|
-
const label = `${one.icon === null ? "" : strokeIcon(CHIP_ICONS[one.icon])}${one.label}`;
|
|
21584
|
-
return `<li class="verdict-chip verdict-chip--${one.tone}" title="${escape(one.title ?? label)}">${label}</li>`;
|
|
21585
|
-
});
|
|
21586
|
-
return `<ul class="verdict-chips" aria-label="Result at a glance">${items.join("")}</ul>` + (by === null ? "" : `<p class="meta verdict-by">Completed by ${escape(by)}</p>`);
|
|
21587
|
-
}
|
|
21588
22438
|
/** The outcome in one bounded sentence (repair 2026-09-14): the handoff's
|
|
21589
22439
|
* first sentence, cut at a word when it runs past 180 characters. The
|
|
21590
22440
|
* full conclusion stays available behind the Summary's disclosure. */
|
|
@@ -22003,6 +22853,13 @@ function telegramDeliveryWords(store, bot) {
|
|
|
22003
22853
|
return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
|
|
22004
22854
|
return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
|
|
22005
22855
|
}
|
|
22856
|
+
/** The projects this person can see, by name, and whether they muted each one's pings. */
|
|
22857
|
+
function notificationProjects(store, account) {
|
|
22858
|
+
const muted = new Set(store.mutedProjects(account));
|
|
22859
|
+
return store.listProjects().filter(one => store.accountCanAccess(account, one.path))
|
|
22860
|
+
.map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
|
|
22861
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
22862
|
+
}
|
|
22006
22863
|
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) {
|
|
22007
22864
|
const permissionCard = permissionDefault === null
|
|
22008
22865
|
? ""
|
|
@@ -22047,6 +22904,14 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
|
|
|
22047
22904
|
`<p class="meta">One message: what finished, what waits, what failed.</p>`,
|
|
22048
22905
|
`<button type="submit">Save</button>`,
|
|
22049
22906
|
`</form>`,
|
|
22907
|
+
...((chatNotices.projects ?? []).length === 0 ? [] : [
|
|
22908
|
+
"<h3>Projects</h3>",
|
|
22909
|
+
`<ul class="card">${chatNotices.projects.map(one => `<li><form method="post" action="/settings/notifications/mute">` +
|
|
22910
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="repo" value="${escape(one.repo)}">` +
|
|
22911
|
+
(one.muted ? "" : `<input type="hidden" name="pings" value="on">`) +
|
|
22912
|
+
`${escape(one.name)} <span class="meta">· ${one.muted ? "muted" : "pings on"}</span> <button type="submit">${one.muted ? "Unmute" : "Mute"}</button></form></li>`).join("")}</ul>`,
|
|
22913
|
+
`<p class="meta">Muted projects still show in Tasks and the evening digest.</p>`,
|
|
22914
|
+
]),
|
|
22050
22915
|
].join("\n");
|
|
22051
22916
|
const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
|
|
22052
22917
|
? ""
|
|
@@ -22328,6 +23193,7 @@ function settingsTiles() {
|
|
|
22328
23193
|
const SETTINGS_TILE_ICONS = [
|
|
22329
23194
|
["/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"/>`],
|
|
22330
23195
|
["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
|
|
23196
|
+
["/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"/>`],
|
|
22331
23197
|
["/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"/>`],
|
|
22332
23198
|
["/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"/>`],
|
|
22333
23199
|
["/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"/>`],
|