toolroll 0.8.1 → 0.9.0
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/agent-onboard.d.ts +7 -0
- package/dist/agent-onboard.js +23 -5
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -20
- package/dist/browser-workspace.d.ts +10 -0
- package/dist/chat-delivery.js +51 -3
- package/dist/chat-quiet.d.ts +28 -0
- package/dist/chat-quiet.js +161 -0
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/digest-times.d.ts +3 -0
- package/dist/digest-times.js +7 -0
- package/dist/dispose.js +2 -0
- package/dist/export.js +1 -1
- package/dist/first-run.d.ts +4 -1
- package/dist/first-run.js +2 -1
- package/dist/integrations-ui.d.ts +13 -0
- package/dist/integrations-ui.js +65 -0
- package/dist/integrations.d.ts +113 -0
- package/dist/integrations.js +526 -0
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +5 -6
- package/dist/operate.js +254 -25
- package/dist/project-delete.js +1 -0
- package/dist/prompt.d.ts +7 -2
- package/dist/prompt.js +36 -7
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +9 -2
- package/dist/pull-request-flow.d.ts +140 -0
- package/dist/pull-request-flow.js +440 -0
- package/dist/pull-request-schema.d.ts +5 -0
- package/dist/pull-request-schema.js +21 -0
- package/dist/pull-request-ui.d.ts +23 -0
- package/dist/pull-request-ui.js +55 -0
- package/dist/pulls.d.ts +5 -0
- package/dist/pulls.js +11 -0
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +323 -26
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +66 -5
- package/dist/store.js +160 -19
- package/dist/surface.js +2 -0
- package/dist/telegram-progress.d.ts +1 -0
- package/dist/telegram-progress.js +1 -1
- package/dist/telegram.js +95 -15
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +1 -1
- package/dist/toolroll-update.d.ts +26 -6
- package/dist/toolroll-update.js +188 -50
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -56,6 +56,8 @@ import { prepareWorkspaceRevision, WorkspaceValidatorCache } from "./workspace-r
|
|
|
56
56
|
import { workIndexPage, workCountsByProject, WorkIndexCursorError } from "./work-index.js";
|
|
57
57
|
import { openWorkDecisionOf } from "./work-summary.js";
|
|
58
58
|
import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
|
|
59
|
+
import { pullRequestSettingsHtml, PULL_REQUEST_SETTINGS_CSS } from './pull-request-ui.js';
|
|
60
|
+
import { checkPublishing, completeAndOpenPullRequest, mergeAsPerson, pullRequestBlocker, pullRequestViewOf, publishingOf, saveMergeSettings, savePublishing, MERGE_METHODS } from './pull-request-flow.js';
|
|
59
61
|
import { leadBriefHtml, LEAD_CONTEXT_CSS } from './lead-context.js';
|
|
60
62
|
import { assignmentCatchUp } from './assignment-brief.js';
|
|
61
63
|
import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml, assignmentWithEvidence, ASSIGNMENT_CSS } from './assignment-ui.js';
|
|
@@ -167,7 +169,8 @@ import { dirname } from "node:path";
|
|
|
167
169
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
168
170
|
import { loadOrCreateVapidKeys, validatePushEndpoint } from "./push.js";
|
|
169
171
|
import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, listGithubRepos, isLargeRepo } from "./onboard.js";
|
|
170
|
-
import { verifiedAuthor, LEAD_THREAD } from "./store.js";
|
|
172
|
+
import { verifiedAuthor, LEAD_THREAD, isDigestTime } from "./store.js";
|
|
173
|
+
import { digestTimes } from "./digest-times.js";
|
|
171
174
|
import { updateRepos, addRepos, removeRepos } from "./repos.js";
|
|
172
175
|
import { run as execRun } from "./exec.js";
|
|
173
176
|
import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskSuggestions, SANDBOX_COMMAND } from "./first-run.js";
|
|
@@ -201,11 +204,16 @@ import { accessFromGroups, accountNameFor, discoverOidc, exchangeOidcCode, newOi
|
|
|
201
204
|
import { readSsoSettings, removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "./sso-settings.js";
|
|
202
205
|
import { monitoringChange, readMonitoring, saveMonitoring } from "./monitoring-settings.js";
|
|
203
206
|
import { MONITORING_CSS, monitoringHtml, signingSecretHtml } from "./monitoring-ui.js";
|
|
207
|
+
import { INTEGRATIONS_CSS, integrationsHtml } from "./integrations-ui.js";
|
|
208
|
+
import { createIntegrationMonitor } from "./integrations.js";
|
|
204
209
|
import { BACKUP_CSS, backupHtml } from "./backup-ui.js";
|
|
205
210
|
import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder } from "./backup.js";
|
|
206
211
|
import { prometheusMetrics } from "./metrics.js";
|
|
207
212
|
import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
|
|
208
213
|
import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
|
|
214
|
+
import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
|
|
215
|
+
import { bytesWords, parseCleanup } from "./storage.js";
|
|
216
|
+
import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./checkout-cleanup.js";
|
|
209
217
|
import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
|
|
210
218
|
import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
|
|
211
219
|
import { latestVersionNow } from "./releases.js";
|
|
@@ -393,6 +401,8 @@ export function createDecisionServer(options) {
|
|
|
393
401
|
}
|
|
394
402
|
}
|
|
395
403
|
const clock = options.clock ?? (() => new Date());
|
|
404
|
+
/** Task checkouts, for Settings → Storage: the pool's own root, or the folder beside the database. */
|
|
405
|
+
const storagePool = (databaseFile) => new WorktreePool(store, { root: options.poolRoot ?? join(dirname(databaseFile), "worktrees") });
|
|
396
406
|
const install = installMethod(options.installBin);
|
|
397
407
|
/** What the last daily check found (the cache only, never the network), or nothing while checks are off. */
|
|
398
408
|
const updateFacts = () => {
|
|
@@ -433,6 +443,19 @@ export function createDecisionServer(options) {
|
|
|
433
443
|
catch { /* the pause keeps its own state */ }
|
|
434
444
|
return value;
|
|
435
445
|
};
|
|
446
|
+
// Settings → Integrations: checked in the background with a short cache, as the first-run suggestions are.
|
|
447
|
+
const integrations = createIntegrationMonitor(() => ({
|
|
448
|
+
store,
|
|
449
|
+
dir: options.configDir ?? null,
|
|
450
|
+
telegramTokenFile: options.telegramTokenFile ?? null,
|
|
451
|
+
env: process.env,
|
|
452
|
+
repos: managedRepos(),
|
|
453
|
+
gh: options.firstTaskRunner ?? execRun,
|
|
454
|
+
checkConnection: connectionCheck,
|
|
455
|
+
toolHome,
|
|
456
|
+
clock,
|
|
457
|
+
...options.integrationIo,
|
|
458
|
+
}));
|
|
436
459
|
const modelCatalog = openRouterModelsCache(options.modelCatalogFetcher);
|
|
437
460
|
const modelSeams = {
|
|
438
461
|
home: providerHome,
|
|
@@ -1411,7 +1434,7 @@ export function createDecisionServer(options) {
|
|
|
1411
1434
|
!url.searchParams.has('task') && !url.searchParams.has('result') && !url.searchParams.has('request')) {
|
|
1412
1435
|
const key = createHash('sha256').update(JSON.stringify([workspaceIncarnation, who.name,
|
|
1413
1436
|
who.session.generation, who.session.csrf, who.session.project, who.session.projectRevision,
|
|
1414
|
-
admissionList(), url.pathname + url.search])).digest('hex');
|
|
1437
|
+
admissionList(), url.pathname + url.search, localSignIn?.signedIn ?? null])).digest('hex');
|
|
1415
1438
|
const revision = workspaceRevision.current(), now = clock();
|
|
1416
1439
|
const prior = workspaceValidators.get(key);
|
|
1417
1440
|
if (prior && prior.revision === revision && prior.expiresAt > now.getTime() && request.headers['if-none-match'] === prior.etag) {
|
|
@@ -1561,7 +1584,7 @@ export function createDecisionServer(options) {
|
|
|
1561
1584
|
}
|
|
1562
1585
|
const read = new Set(["/settings/skills", "/settings/knowledge", "/settings", "/settings/learning", "/recipes", "/recipes/run", "/recipes/start", "/recipes/new", "/recipes/edit", "/recipes/from-task", "/recipes/preview", "/recipes/export", "/", "/inbox", "/work", "/projects", "/people", "/ledger", "/ledger/export", "/next", "/board", "/tasks", "/tasks/new", "/runs", "/review", "/done", "/routines", "/menu"]);
|
|
1563
1586
|
const write = new Set(["/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"]);
|
|
1564
|
-
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|stop|resume-arm|resume)$");
|
|
1587
|
+
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)$");
|
|
1565
1588
|
const resource = request.method === "GET"
|
|
1566
1589
|
? /^\/(?: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)
|
|
1567
1590
|
: /^\/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);
|
|
@@ -3300,7 +3323,7 @@ export function createDecisionServer(options) {
|
|
|
3300
3323
|
back: { href: roomId ? "/chat?conversation=" + encodeURIComponent(roomId) : taskChatHref(focusTask?.id ?? ""), label: "Back to chat" },
|
|
3301
3324
|
}) + (who.role === 'approver' && focusTask?.assignment?.state === 'ready-to-check'
|
|
3302
3325
|
&& focusTask.assignment.receipt?.runId === resultRun.id
|
|
3303
|
-
? completionForm(focusTask.assignment.receipt.taskId, resultRun.id, focusTask.assignment.receipt.digest, who.session.csrf) : '');
|
|
3326
|
+
? completionForm(focusTask.assignment.receipt.taskId, resultRun.id, focusTask.assignment.receipt.digest, who.session.csrf, pullRequestTargetOf(resultRun.id)) : '');
|
|
3304
3327
|
const focusProblem = requestedTask !== null && focusTask === null
|
|
3305
3328
|
? "That task is not available in this workspace."
|
|
3306
3329
|
: requestedResult !== null && focusTask !== null && resultRun === null
|
|
@@ -3616,6 +3639,13 @@ export function createDecisionServer(options) {
|
|
|
3616
3639
|
sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name) };
|
|
3617
3640
|
return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions & tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
3618
3641
|
}
|
|
3642
|
+
// Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
|
|
3643
|
+
if (url.pathname === "/settings/integrations") {
|
|
3644
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
3645
|
+
return refuse(response, who, 403, "An installation approver sees integrations.", "/settings");
|
|
3646
|
+
const list = integrations.list();
|
|
3647
|
+
return sendScreen(response, 200, screen("Integrations", `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking })}`, { chrome: chromeFor(project, "settings") }));
|
|
3648
|
+
}
|
|
3619
3649
|
// v104: Settings → Monitoring, where the audit stream and traces go. An instance operator's page.
|
|
3620
3650
|
if (url.pathname === "/settings/monitoring") {
|
|
3621
3651
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
|
|
@@ -3635,7 +3665,7 @@ export function createDecisionServer(options) {
|
|
|
3635
3665
|
const current = options.updates?.current ?? PACKAGE_VERSION;
|
|
3636
3666
|
// While update checks are off the page asks npm nothing until Check now.
|
|
3637
3667
|
const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off && url.searchParams.get("check") !== "now";
|
|
3638
|
-
const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, csrf: who.session.csrf };
|
|
3668
|
+
const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, rollbackTo: status.lastUpdate?.to === current ? status.lastUpdate.from : null, csrf: who.session.csrf };
|
|
3639
3669
|
return sendScreen(response, 200, screen("Updates", `<p><a href="/settings">Settings</a></p><h1>Updates</h1>${updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
|
|
3640
3670
|
}
|
|
3641
3671
|
// v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
|
|
@@ -3645,6 +3675,29 @@ export function createDecisionServer(options) {
|
|
|
3645
3675
|
const view = { periods: store.retentionPeriods(), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
|
|
3646
3676
|
return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
3647
3677
|
}
|
|
3678
|
+
// Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
|
|
3679
|
+
// finished task's clean checkout goes by itself. An instance operator's page.
|
|
3680
|
+
if (url.pathname === "/settings/storage") {
|
|
3681
|
+
const databaseFile = store.databaseFile();
|
|
3682
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
3683
|
+
return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
|
|
3684
|
+
const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
|
|
3685
|
+
return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
|
|
3686
|
+
}
|
|
3687
|
+
// Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
|
|
3688
|
+
// exactly what turning on would do, or what to fix first.
|
|
3689
|
+
if (url.pathname === "/settings/pull-requests") {
|
|
3690
|
+
const repo = url.searchParams.get("repo") ?? "";
|
|
3691
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
3692
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
3693
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
3694
|
+
const publishing = publishingOf(store, repo);
|
|
3695
|
+
const canChange = who.role === "approver" && !store.isDemo();
|
|
3696
|
+
const check = publishing.on || !canChange ? null : await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
|
|
3697
|
+
const html = pullRequestSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange, publishing, check,
|
|
3698
|
+
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
3699
|
+
return sendScreen(response, 200, screen("Pull requests", `<p><a href="/projects">Projects</a></p><h1>Pull requests</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
3700
|
+
}
|
|
3648
3701
|
// Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
|
|
3649
3702
|
if (url.pathname === "/settings/backups") {
|
|
3650
3703
|
const databaseFile = store.databaseFile();
|
|
@@ -3819,6 +3872,9 @@ export function createDecisionServer(options) {
|
|
|
3819
3872
|
// The installation fact: when the first Ready result arrived, measured from the first account.
|
|
3820
3873
|
const at = store.firstSuccessAt(now), since = store.installationStartedAt();
|
|
3821
3874
|
return at === null || since === null ? null : firstResultWords(since, at);
|
|
3875
|
+
})(), (() => {
|
|
3876
|
+
const chosen = store.notificationPreference(who.name);
|
|
3877
|
+
return { mode: chosen.mode, digestAt: chosen.digestAt };
|
|
3822
3878
|
})()));
|
|
3823
3879
|
}
|
|
3824
3880
|
if (url.pathname === "/push/key") {
|
|
@@ -3873,17 +3929,19 @@ export function createDecisionServer(options) {
|
|
|
3873
3929
|
/**
|
|
3874
3930
|
* Whether a coding agent is signed in where the work runs. This machine's
|
|
3875
3931
|
* own checks (the ones Settings makes) speak only for a worker on this
|
|
3876
|
-
* machine
|
|
3932
|
+
* machine, or for the first one while none is registered yet (it is started
|
|
3933
|
+
* here); a worker elsewhere speaks through its own readiness report, and
|
|
3877
3934
|
* the console never claims to have checked that machine (finding 15).
|
|
3878
3935
|
* Nothing here waits on a probe: this machine's answer is the last one
|
|
3879
|
-
* seen, refreshed in the background.
|
|
3936
|
+
* seen, refreshed in the background, and null until the first one is in.
|
|
3880
3937
|
*/
|
|
3881
3938
|
function agentSignedIn(now) {
|
|
3882
3939
|
const here = hostname();
|
|
3883
|
-
const
|
|
3884
|
-
|
|
3940
|
+
const registered = store.listRunners().filter(one => one.retiredAt === null);
|
|
3941
|
+
const workers = registered.filter(one => runnerAlive(one, now));
|
|
3942
|
+
if (workers.some(one => one.host !== here && store.providerReadiness(one.name).some(seen => seen.state === "ready")))
|
|
3885
3943
|
return true;
|
|
3886
|
-
return workers.some(one => one.host
|
|
3944
|
+
return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
|
|
3887
3945
|
}
|
|
3888
3946
|
let localSignIn = null;
|
|
3889
3947
|
let localSignInChecking = false;
|
|
@@ -3895,7 +3953,7 @@ export function createDecisionServer(options) {
|
|
|
3895
3953
|
.catch(() => { })
|
|
3896
3954
|
.finally(() => { localSignInChecking = false; });
|
|
3897
3955
|
}
|
|
3898
|
-
return localSignIn?.signedIn ??
|
|
3956
|
+
return localSignIn?.signedIn ?? null;
|
|
3899
3957
|
}
|
|
3900
3958
|
/** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
|
|
3901
3959
|
* every ten minutes. A render never waits on `gh` or `git grep`. */
|
|
@@ -3923,7 +3981,7 @@ export function createDecisionServer(options) {
|
|
|
3923
3981
|
return {
|
|
3924
3982
|
steps,
|
|
3925
3983
|
suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
|
|
3926
|
-
sandbox: steps.find(one => one.key === "agent"
|
|
3984
|
+
sandbox: steps.find(one => one.key === "agent" && !one.done && !one.checking) ? SANDBOX_COMMAND : null,
|
|
3927
3985
|
};
|
|
3928
3986
|
}
|
|
3929
3987
|
// ---- fleet chat (v13) ----------------------------------------------------
|
|
@@ -4887,7 +4945,7 @@ export function createDecisionServer(options) {
|
|
|
4887
4945
|
peeks[one.path] = peekOf(one.path);
|
|
4888
4946
|
for (const path of candidates)
|
|
4889
4947
|
peeks[path] = peekOf(path);
|
|
4890
|
-
return sendScreen(response, status, projectsPage(chromeFor(open, "projects"), recent, [...candidates], open, csrf, problem, !restricted() && unscopedMode, !restricted() && (ceiling.roots.length > 0 || unscopedMode), onboardState, peeks, returnTo));
|
|
4948
|
+
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));
|
|
4891
4949
|
}
|
|
4892
4950
|
/** The task screen, shared by the GET and by every refusal that re-renders it. */
|
|
4893
4951
|
/**
|
|
@@ -5345,6 +5403,41 @@ export function createDecisionServer(options) {
|
|
|
5345
5403
|
: completionReceiptView(store, latest, store.artifactsFor(latest.id), evidenceRoot, reviewFactsFor(latest.id)),
|
|
5346
5404
|
};
|
|
5347
5405
|
}
|
|
5406
|
+
/** The newest pull request any version of this task opened through Complete; else, for a result completed
|
|
5407
|
+
* without one, the offer to open it (an approver's browser session only). */
|
|
5408
|
+
/** Where "Complete and open a pull request" would open one for this result, or null when it isn't offered. */
|
|
5409
|
+
function pullRequestTargetOf(runId) {
|
|
5410
|
+
const run = store.getRun(runId);
|
|
5411
|
+
const publishing = publishingOf(store, run === null ? null : store.refById(run.taskRef)?.repo ?? null);
|
|
5412
|
+
if (!publishing.on || publishing.legacy || store.publicationForRun(runId) !== null || pullRequestBlocker(store, runId) !== null)
|
|
5413
|
+
return null;
|
|
5414
|
+
return `${publishing.githubRepo} into ${publishing.base}`;
|
|
5415
|
+
}
|
|
5416
|
+
function taskPullRequestOf(taskId, who) {
|
|
5417
|
+
const family = familyOf(taskId);
|
|
5418
|
+
const rootId = family?.root.id ?? taskId;
|
|
5419
|
+
const ids = family === null ? [taskId] : [...family.versions.map(one => one.id)].reverse();
|
|
5420
|
+
const repo = store.lookupRef(taskId)?.repo ?? null;
|
|
5421
|
+
const publishing = publishingOf(store, repo);
|
|
5422
|
+
const target = publishing.on ? publishing.base : null;
|
|
5423
|
+
for (const id of ids) {
|
|
5424
|
+
const ref = store.lookupRef(id);
|
|
5425
|
+
if (ref === null)
|
|
5426
|
+
continue;
|
|
5427
|
+
for (const run of store.runsFor(ref.id)) {
|
|
5428
|
+
const view = pullRequestViewOf(store, run.id);
|
|
5429
|
+
if (view !== null)
|
|
5430
|
+
return { taskId: rootId, view: { ...view, canMerge: view.canMerge && who.role === "approver" && who.via === "cookie" }, offer: null, target };
|
|
5431
|
+
}
|
|
5432
|
+
}
|
|
5433
|
+
if (!publishing.on || who.via !== "cookie" || who.role !== "approver")
|
|
5434
|
+
return null;
|
|
5435
|
+
const assignment = assignmentOf(store, taskId, clock(), { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) }, evidenceRoot);
|
|
5436
|
+
const receipt = assignment?.receipt ?? null;
|
|
5437
|
+
if (assignment?.state !== "complete" || receipt === null || store.publicationForRun(receipt.runId) !== null || pullRequestBlocker(store, receipt.runId) !== null)
|
|
5438
|
+
return null;
|
|
5439
|
+
return { taskId: rootId, view: null, offer: { taskId: receipt.taskId, runId: receipt.runId, digest: receipt.digest }, target };
|
|
5440
|
+
}
|
|
5348
5441
|
function taskScreen(response, who, taskId, problem, status, scopeDraft, cancelDraft) {
|
|
5349
5442
|
const admittedFamily = familyOf(taskId);
|
|
5350
5443
|
if (admittedFamily?.problem != null)
|
|
@@ -5360,6 +5453,7 @@ export function createDecisionServer(options) {
|
|
|
5360
5453
|
presentedData.scopeDraft = scopeDraft;
|
|
5361
5454
|
if (cancelDraft !== undefined)
|
|
5362
5455
|
presentedData.cancelDraft = cancelDraft;
|
|
5456
|
+
presentedData.pullRequest = taskPullRequestOf(taskId, who);
|
|
5363
5457
|
const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
|
|
5364
5458
|
const page = taskPage(paneProject === null && !unscopedMode
|
|
5365
5459
|
? chromeFor(paneProject, "tasks")
|
|
@@ -6620,6 +6714,75 @@ export function createDecisionServer(options) {
|
|
|
6620
6714
|
store.setRetentionPeriod(one.kind, one.days ?? null, who.name, now);
|
|
6621
6715
|
return back("said", changed.length === 0 ? "Nothing changed." : "Saved. The next daily sweep uses these periods.");
|
|
6622
6716
|
}
|
|
6717
|
+
// Settings → Storage: when a finished task's clean checkout goes, cleaning up what the preview showed, and throwing
|
|
6718
|
+
// away a checkout kept for its changes. Each takes the password; the ledger keeps every change and removal.
|
|
6719
|
+
if (url.pathname === "/settings/storage" || url.pathname === "/settings/storage/clean" || url.pathname === "/settings/storage/discard") {
|
|
6720
|
+
const databaseFile = store.databaseFile();
|
|
6721
|
+
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
6722
|
+
return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
|
|
6723
|
+
const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
|
|
6724
|
+
const what = url.pathname.endsWith("/clean") ? "clean up" : url.pathname.endsWith("/discard") ? "discard a checkout" : "change checkout cleanup";
|
|
6725
|
+
if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
|
|
6726
|
+
return back("problem", `Enter your Toolroll password to ${what}.`);
|
|
6727
|
+
if (url.pathname === "/settings/storage") {
|
|
6728
|
+
const cleanup = parseCleanup(body.get("cleanup") ?? "");
|
|
6729
|
+
if (cleanup === undefined)
|
|
6730
|
+
return back("problem", "Choose when a finished task's checkout is removed.");
|
|
6731
|
+
const before = store.checkoutCleanup();
|
|
6732
|
+
store.setCheckoutCleanup(cleanup, who.name, now);
|
|
6733
|
+
return back("said", cleanup === before ? "Nothing changed." : "Saved.");
|
|
6734
|
+
}
|
|
6735
|
+
const pool = storagePool(databaseFile);
|
|
6736
|
+
if (url.pathname.endsWith("/clean")) {
|
|
6737
|
+
// Only what the preview showed goes: when that changed since, the person looks again.
|
|
6738
|
+
const plan = await checkoutPlan(store, pool, clock(), { manual: true });
|
|
6739
|
+
if (plan.go.length === 0)
|
|
6740
|
+
return back("said", "Nothing to clean up.");
|
|
6741
|
+
if (body.get("preview") !== previewDigest(plan))
|
|
6742
|
+
return back("problem", "What a clean-up would remove changed since you looked. Check the list again.");
|
|
6743
|
+
const done = await cleanCheckouts(store, pool, clock, { manual: true, actor: who.name, only: new Set(plan.go.map(one => one.path)) });
|
|
6744
|
+
return back("said", done.removed.length === 0 ? "Nothing was removed: those checkouts are no longer ready to go." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}. Their branches stay.`);
|
|
6745
|
+
}
|
|
6746
|
+
const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
|
|
6747
|
+
return done.ok ? back("said", `Discarded, about ${bytesWords(done.bytes)}. Its branch stays.`) : back("problem", done.message);
|
|
6748
|
+
}
|
|
6749
|
+
// Pull requests for one project: turn on (the checks run again and must match what the page showed), merge
|
|
6750
|
+
// settings, or turn off. Each takes the password and lands in the ledger.
|
|
6751
|
+
if (url.pathname === "/settings/pull-requests") {
|
|
6752
|
+
const repo = body.get("repo") ?? "";
|
|
6753
|
+
const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
|
|
6754
|
+
if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
|
|
6755
|
+
return refuse(response, who, 404, "No such project.", "/projects");
|
|
6756
|
+
if (who.role !== "approver" || store.isDemo())
|
|
6757
|
+
return refuse(response, who, 403, "An approver sets up pull requests.", "/projects");
|
|
6758
|
+
const back = (key, words) => redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
6759
|
+
const act = body.get("act");
|
|
6760
|
+
if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
|
|
6761
|
+
return back("problem", "Enter your Toolroll password to change pull requests.");
|
|
6762
|
+
if (act === "on") {
|
|
6763
|
+
const checked = await checkPublishing(repo, options.publishExec === undefined ? {} : { exec: options.publishExec });
|
|
6764
|
+
if (!checked.ok)
|
|
6765
|
+
return back("problem", checked.message);
|
|
6766
|
+
if (checked.plan.githubRepo !== body.get("github") || checked.plan.base !== body.get("base"))
|
|
6767
|
+
return back("problem", "GitHub answered differently from what you saw. Check the details again.");
|
|
6768
|
+
savePublishing(store, checked.plan, who.name, {}, now);
|
|
6769
|
+
return back("said", "Pull requests are on.");
|
|
6770
|
+
}
|
|
6771
|
+
if (act === "settings") {
|
|
6772
|
+
const method = body.get("method") ?? "";
|
|
6773
|
+
if (!MERGE_METHODS.includes(method))
|
|
6774
|
+
return back("problem", "Choose a merge method.");
|
|
6775
|
+
const saved = saveMergeSettings(store, repo, { mergeMethod: method, mergeWhenGreen: body.get("when-green") === "1" }, who.name, now);
|
|
6776
|
+
return saved.ok ? back("said", "Saved.") : back("problem", saved.message);
|
|
6777
|
+
}
|
|
6778
|
+
if (act === "off") {
|
|
6779
|
+
const revoked = store.revokePublicationGrant(repo, who.name, now);
|
|
6780
|
+
if (revoked)
|
|
6781
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId: null, runId: null, action: "pull requests turned off", outcome: "off", source: "policy" });
|
|
6782
|
+
return back("said", revoked ? "Pull requests are off." : "Pull requests were already off.");
|
|
6783
|
+
}
|
|
6784
|
+
return back("problem", "That change isn't available.");
|
|
6785
|
+
}
|
|
6623
6786
|
// Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
|
|
6624
6787
|
if (url.pathname === "/settings/backups/now") {
|
|
6625
6788
|
const databaseFile = store.databaseFile();
|
|
@@ -6668,6 +6831,18 @@ export function createDecisionServer(options) {
|
|
|
6668
6831
|
"cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
6669
6832
|
return void response.end(zip);
|
|
6670
6833
|
}
|
|
6834
|
+
// Settings → Integrations → Send test: one harmless read-only check of one integration, now.
|
|
6835
|
+
if (url.pathname === "/settings/integrations/test") {
|
|
6836
|
+
if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
|
|
6837
|
+
return refuse(response, who, 403, "An installation approver tests integrations.", "/settings");
|
|
6838
|
+
const key = body.get("key") ?? "";
|
|
6839
|
+
const back = (name, words) => redirect(response, `/settings/integrations?${name}=${encodeURIComponent(words)}`);
|
|
6840
|
+
const checked = (await integrations.check([key])).find(one => one.key === key);
|
|
6841
|
+
if (checked === undefined)
|
|
6842
|
+
return back("problem", "That integration isn't here any more.");
|
|
6843
|
+
return checked.state === "connected" ? back("said", `${checked.name} works${checked.account === null ? "" : ` (${checked.account})`}.`)
|
|
6844
|
+
: back("problem", `${checked.name}: ${checked.action.kind === "fix" ? checked.action.words : "not set up."}`);
|
|
6845
|
+
}
|
|
6671
6846
|
// v104: change where monitoring goes. A step-up; the ledger keeps before → after (addresses, never secrets).
|
|
6672
6847
|
if (url.pathname === "/settings/monitoring") {
|
|
6673
6848
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
|
|
@@ -7227,6 +7402,20 @@ export function createDecisionServer(options) {
|
|
|
7227
7402
|
? "New tasks now use Strict / release. Approved tasks keep their setting."
|
|
7228
7403
|
: "New tasks now use Default quality. Approved tasks keep their setting.")}`);
|
|
7229
7404
|
}
|
|
7405
|
+
if (url.pathname === "/settings/notifications") {
|
|
7406
|
+
// Each person's own choice: a closed list of modes and a 24-hour HH:MM, or off.
|
|
7407
|
+
const mode = body.get("mode"), time = (body.get("digest") ?? "").trim();
|
|
7408
|
+
if (mode !== null && mode !== "quiet" && mode !== "all")
|
|
7409
|
+
return refuse(response, who, 400, "choose Only when I'm needed or Every step", "/settings");
|
|
7410
|
+
if (time !== "" && time !== "off" && !isDigestTime(time))
|
|
7411
|
+
return refuse(response, who, 400, "the evening digest time is HH:MM, or off", "/settings");
|
|
7412
|
+
const before = store.notificationPreference(who.name);
|
|
7413
|
+
const after = store.setNotificationPreference(who.name, { ...(mode === null ? {} : { mode }), ...(time === "" ? {} : { digestAt: time === "off" ? null : time }) }, who.name, now);
|
|
7414
|
+
const said = after.mode !== before.mode
|
|
7415
|
+
? after.mode === "quiet" ? "Chats now message you only when you're needed." : "Chats now message you at every step."
|
|
7416
|
+
: after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
|
|
7417
|
+
return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
|
|
7418
|
+
}
|
|
7230
7419
|
if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
|
|
7231
7420
|
// The cadence is a closed list of minutes — never a free number from
|
|
7232
7421
|
// a form; "off" clears it. Any approver session may set it.
|
|
@@ -7958,7 +8147,28 @@ export function createDecisionServer(options) {
|
|
|
7958
8147
|
if (attendAct !== null) {
|
|
7959
8148
|
return attendMutation(response, who, attendAct.taskId, attendAct.verb, body, now);
|
|
7960
8149
|
}
|
|
7961
|
-
const act = matchTaskPath(url.pathname, "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|block|unblock|repair-dependency|next|reopen|steer|follow-up|accept-proof|accept-revision|reject-revision|route|retry-review|complete|stop|resume-arm|resume)$");
|
|
8150
|
+
const act = matchTaskPath(url.pathname, "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|block|unblock|repair-dependency|next|reopen|steer|follow-up|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|stop|resume-arm|resume)$");
|
|
8151
|
+
if (act !== null && act.verb === "merge") {
|
|
8152
|
+
// Merging is a person's act behind their password, typed again: a browser session, an approver, the exact
|
|
8153
|
+
// result's pull request. The flow re-reads GitHub before merging and records who merged in the ledger.
|
|
8154
|
+
if (who.via !== "cookie" || who.role !== "approver")
|
|
8155
|
+
return refuse(response, who, 403, "Only an approver can merge.");
|
|
8156
|
+
if (store.isDemo())
|
|
8157
|
+
return taskScreen(response, who, act.taskId, "The demo merges nothing.", 403);
|
|
8158
|
+
const ref = store.lookupRef(act.taskId);
|
|
8159
|
+
if (ref === null || !visible(ref.repo))
|
|
8160
|
+
return refuse(response, who, 404, "no such task", "/tasks");
|
|
8161
|
+
const named = (body.get("run") ?? "").trim();
|
|
8162
|
+
const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
|
|
8163
|
+
const runTask = run === null ? null : store.externalIdFor(run.taskRef);
|
|
8164
|
+
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
8165
|
+
return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
|
|
8166
|
+
}
|
|
8167
|
+
const merged = await mergeAsPerson(store, { runId: run.id, name: who.name, password: body.get("token") ?? "", ...(options.publishExec === undefined ? {} : { exec: options.publishExec }), clock });
|
|
8168
|
+
if (!merged.ok)
|
|
8169
|
+
return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
|
|
8170
|
+
return redirect(response, `${taskHref(act.taskId)}#merge`);
|
|
8171
|
+
}
|
|
7962
8172
|
if (act !== null) {
|
|
7963
8173
|
return taskMutation(response, who, act.taskId, act.verb, body, now);
|
|
7964
8174
|
}
|
|
@@ -9995,6 +10205,14 @@ export function createDecisionServer(options) {
|
|
|
9995
10205
|
if (current === null || current.receipt?.taskId !== taskId || !/^[0-9]{1,15}$/.test(namedRun) || current.receipt.runId !== Number(namedRun)) {
|
|
9996
10206
|
return taskScreen(response, who, taskId, "This result changed. Open the current result before marking it complete.", 409);
|
|
9997
10207
|
}
|
|
10208
|
+
if (body.get("publish") === "1") {
|
|
10209
|
+
// "Complete and open a pull request": the same exact-receipt completion, plus the owed PR for this
|
|
10210
|
+
// exact commit in one transaction. The watch process pushes and opens it; the task shows its progress.
|
|
10211
|
+
const opened = completeAndOpenPullRequest(store, { taskId, digest, runId: Number(namedRun), who: principal, root: evidenceRoot }, now);
|
|
10212
|
+
if (!opened.ok)
|
|
10213
|
+
return taskScreen(response, who, taskId, opened.message, 409);
|
|
10214
|
+
return redirect(response, `${taskHref(familyOf(taskId)?.root.id ?? taskId)}#merge`);
|
|
10215
|
+
}
|
|
9998
10216
|
const completed = checkAssignmentAsOperator(store, taskId, digest, principal, now, evidenceRoot);
|
|
9999
10217
|
if (!completed.ok)
|
|
10000
10218
|
return taskScreen(response, who, taskId, completed.message, 409);
|
|
@@ -10519,6 +10737,7 @@ export function createDecisionServer(options) {
|
|
|
10519
10737
|
proof,
|
|
10520
10738
|
terminal,
|
|
10521
10739
|
publication,
|
|
10740
|
+
pullRequestTo: pullRequestTargetOf(run.id),
|
|
10522
10741
|
ciFailing: publication !== null && publication.prNumber !== null && store.hasOpenCiEpisode(publication.githubRepo, publication.prNumber),
|
|
10523
10742
|
files,
|
|
10524
10743
|
outsideTouches: files.filter(one => one.outsideTouches).map(one => one.path),
|
|
@@ -12898,6 +13117,23 @@ ${THEME_DARK}
|
|
|
12898
13117
|
.status-line[data-tone="ready"] .status-dot, .status-line[data-tone="done"] .status-dot { background: var(--success); }
|
|
12899
13118
|
.status-line[data-tone="muted"] { color: var(--muted-foreground); font-weight: 500; }
|
|
12900
13119
|
.status-line[data-tone="muted"] .status-dot { background: var(--border); }
|
|
13120
|
+
.pull-request { display: grid; gap: .35rem; }
|
|
13121
|
+
.pull-request p { margin: 0; overflow-wrap: anywhere; }
|
|
13122
|
+
.pull-request-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .75rem; }
|
|
13123
|
+
.pull-request-head a, .pull-request .meta a { display: inline-flex; align-items: center; min-height: 44px; }
|
|
13124
|
+
.pull-request-head strong { font-size: 1.0625rem; font-weight: 600; color: var(--foreground); display: inline-flex; align-items: center; gap: .5rem; }
|
|
13125
|
+
.pull-request-head strong::before { content: ""; width: .5rem; height: .5rem; border-radius: 999px; background: var(--muted-foreground); }
|
|
13126
|
+
.pull-request--ok .pull-request-head strong::before { background: var(--success, #15803d); }
|
|
13127
|
+
.pull-request--problem .pull-request-head strong::before { background: var(--destructive, #b91c1c); }
|
|
13128
|
+
.pull-request-act { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .5rem .75rem; margin: .25rem 0 0; }
|
|
13129
|
+
.pull-request-act .meta { flex: 1 1 100%; }
|
|
13130
|
+
.pull-request-act label { display: grid; gap: .25rem; flex: 1 1 12rem; margin: 0; }
|
|
13131
|
+
.pull-request-act label input { margin: 0; min-height: 44px; box-sizing: border-box; width: 100%; }
|
|
13132
|
+
.pull-request-act button { min-height: 44px; white-space: nowrap; }
|
|
13133
|
+
.result-complete-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
|
13134
|
+
.result-complete-actions button { white-space: nowrap; }
|
|
13135
|
+
.result-complete-actions button[name=publish], .pull-request-act button { background: var(--primary); color: var(--primary-foreground); border-color: var(--primary); font-weight: 600; }
|
|
13136
|
+
@media (max-width: 480px) { .pull-request-act button, .result-complete-actions button { flex: 1 1 100%; } }
|
|
12901
13137
|
.receipt-publication { position: relative; z-index: 1; margin: .35rem 0 0; font-size: .8125rem; color: var(--muted-foreground); }
|
|
12902
13138
|
/* A raw repository path in ordinary copy wraps at any point rather than
|
|
12903
13139
|
widening the page (the phone task-list overflow, package 1). */
|
|
@@ -14066,7 +14302,7 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
|
|
|
14066
14302
|
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}`;
|
|
14067
14303
|
/** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
|
|
14068
14304
|
export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
|
|
14069
|
-
const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_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 + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_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}');
|
|
14305
|
+
const WORKSPACE_STYLE = styleAsset(STYLE + 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 + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_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}');
|
|
14070
14306
|
/**
|
|
14071
14307
|
* The board's liveness: fetch this page's own fragment on a timer and swap
|
|
14072
14308
|
* it in place — no flicker, no scroll reset, no long-lived stream to manage,
|
|
@@ -14725,7 +14961,7 @@ function inboxPage(chrome, data) {
|
|
|
14725
14961
|
`<p><strong>Get to your first result</strong></p>` +
|
|
14726
14962
|
data.wizard
|
|
14727
14963
|
.map(step => `<p class="row" data-step="${step.key}"><span class="mono" aria-hidden="true">${step.done ? "\u2713" : "\u25cb"}</span> <strong>${escape(step.title)}</strong>` +
|
|
14728
|
-
(step.action === null ? ` <span class="meta">done</span>`
|
|
14964
|
+
(step.checking ? ` <span class="meta">checking…</span>` : step.action === null ? ` <span class="meta">done</span>`
|
|
14729
14965
|
: step.action.kind === "link" ? ` <a href="${escape(step.action.href)}">${escape(step.action.label)}</a>`
|
|
14730
14966
|
: ` <code>${escape(step.action.command)}</code>`) +
|
|
14731
14967
|
`</p>`)
|
|
@@ -17520,7 +17756,7 @@ function githubReposPage(chrome, data) {
|
|
|
17520
17756
|
`<p class="row" style="margin-top:.6rem"><a class="badge" href="/projects">← back to projects</a></p>`,
|
|
17521
17757
|
].join("\n"), { chrome });
|
|
17522
17758
|
}
|
|
17523
|
-
function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/") {
|
|
17759
|
+
function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscopedMode, browsable = false, onboard = null, peeks = {}, returnTo = "/", pullRequestsOn) {
|
|
17524
17760
|
// The onboarding card (repo onboarding, findings 1-39): preview first,
|
|
17525
17761
|
// then a password-confirmed clone into a configured root. Disabled
|
|
17526
17762
|
// states explain themselves in words (finding 28/39).
|
|
@@ -17610,6 +17846,7 @@ function projectsPage(chrome, recent, candidates, open, csrf, problem, _unscoped
|
|
|
17610
17846
|
return {
|
|
17611
17847
|
name, path, shortPath: path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path, open: open !== null && open === path, openedAt,
|
|
17612
17848
|
knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(path)}`,
|
|
17849
|
+
...(pullRequestsOn === undefined ? {} : { pullRequests: { href: `/settings/pull-requests?repo=${encodeURIComponent(path)}`, on: pullRequestsOn(path) } }),
|
|
17613
17850
|
peek: peek === null ? null : [
|
|
17614
17851
|
...(peek.waiting > 0 ? [{ label: `${peek.waiting} waiting on you`, href: "/", tone: "attention" }] : []),
|
|
17615
17852
|
...(peek.running > 0 ? [{ label: `${peek.running} running`, href: "/runs", tone: "neutral" }] : []),
|
|
@@ -18999,6 +19236,9 @@ function taskBodyParts(data) {
|
|
|
18999
19236
|
return "no project — no publication grant can apply";
|
|
19000
19237
|
if (grant === null)
|
|
19001
19238
|
return "branch only — publishing is not set up";
|
|
19239
|
+
if (grant.publishOn === "complete") {
|
|
19240
|
+
return `pull requests to ${grant.githubRepo} into ${grant.base} when completed · ${grant.mergeWhenGreen === true ? "merges when checks pass" : "a person merges"} (${grant.mergeMethod ?? "squash"})`;
|
|
19241
|
+
}
|
|
19002
19242
|
return [
|
|
19003
19243
|
grant.capabilities.includes("push-branch") ? `may push ${grant.headPrefix}* to ${grant.githubRepo}` : "cannot push",
|
|
19004
19244
|
grant.capabilities.includes("open-pr") ? `may open a PR against ${grant.base}${grant.draft ? " (draft)" : ""}` : null,
|
|
@@ -19054,7 +19294,8 @@ function taskBodyParts(data) {
|
|
|
19054
19294
|
facts.push({ label: "Budget", parts: [`${data.budgetHold} · `, { label: "Spend", href: "/spend" }] });
|
|
19055
19295
|
if (data.policyHold != null)
|
|
19056
19296
|
facts.push({ label: "Policy", parts: [`${data.policyHold} · `, { label: "Policy", href: "/settings/policy" }] });
|
|
19057
|
-
|
|
19297
|
+
// The pull-request card already says this once, with its action.
|
|
19298
|
+
if (data.publication !== null && data.publication !== undefined && data.pullRequest?.view == null) {
|
|
19058
19299
|
const prHref = safePrUrl(data.publication.prUrl), pr = `PR #${data.publication.prNumber ?? "?"}`;
|
|
19059
19300
|
facts.push({ label: "Published", parts: [prHref === null ? pr : { label: pr, href: prHref },
|
|
19060
19301
|
` · ${data.publication.state}${data.publication.remoteState !== null ? ` · ${data.publication.remoteState.toLowerCase()} on GitHub` : ""}${data.publication.lastCheckState !== null ? ` · CI ${data.publication.lastCheckState} at last observation` : " · no checks observed"}`] });
|
|
@@ -19235,7 +19476,9 @@ function taskBodyParts(data) {
|
|
|
19235
19476
|
: data.filedVia === null || data.filedVia === undefined
|
|
19236
19477
|
? ""
|
|
19237
19478
|
: ` · filed via ${escape(data.filedVia)}`}${data.deliverable === "report" ? ` · <span class="badge">scout</span>` : ""}</p>`;
|
|
19238
|
-
|
|
19479
|
+
// A pull request that can be merged (or opened) owns the one primary action; the result stays in its section.
|
|
19480
|
+
const pullRequestActs = data.csrf !== "" && (data.pullRequest?.view?.canMerge === true || (data.pullRequest?.view == null && data.pullRequest?.offer != null));
|
|
19481
|
+
const assignmentOptions = data.assignment == null ? null : { workStatus: status, hideAction: (approveForm !== "" && data.dispatch?.action === "approve-scope") || pullRequestActs, problem: status.tone === "problem", diagnostics: [...(status.diagnostics ?? []), ...(status.tone === "problem" && status.detail !== data.assignment.detail ? [status] : [])] };
|
|
19239
19482
|
// The result takes over from the task status as soon as it is ready.
|
|
19240
19483
|
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);
|
|
19241
19484
|
// The exact-run control (v52), directly under the scheduler's answer:
|
|
@@ -19365,6 +19608,7 @@ function taskBodyParts(data) {
|
|
|
19365
19608
|
approval: approvalHtml,
|
|
19366
19609
|
lead: [
|
|
19367
19610
|
{ key: "history", html: data.history ?? "" }, { key: "control", html: controlHtml }, { key: "problem", html: problemHtml },
|
|
19611
|
+
{ key: "pull-request", html: data.pullRequest == null ? "" : pullRequestCardHtml(data.pullRequest, data.csrf) },
|
|
19368
19612
|
{ key: "needs-scope", html: needsScopeCard }, { key: "progress", html: progressCard }, { key: "revisions", html: revisionLedgerCard },
|
|
19369
19613
|
{ key: "plan", html: planCard }, { key: "mirror", html: mirrorCard }, { key: "contest", html: contestCard }, { key: "attempt", html: attemptPanel },
|
|
19370
19614
|
].filter(one => one.html !== ""),
|
|
@@ -19869,7 +20113,7 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
|
|
|
19869
20113
|
const complete = assignment?.state === "ready-to-check" && assignment.receipt !== null && canRetryReview && csrf !== ""
|
|
19870
20114
|
? { action: `${taskHref(view.taskId)}/complete`, receipt: assignment.receipt.digest, run: run.id } : null;
|
|
19871
20115
|
if (complete !== null)
|
|
19872
|
-
parts.push(completionForm(view.taskId, run.id, complete.receipt, csrf));
|
|
20116
|
+
parts.push(completionForm(view.taskId, run.id, complete.receipt, csrf, view.detail?.pullRequestTo ?? null));
|
|
19873
20117
|
if (view.notes.length > 0) {
|
|
19874
20118
|
parts.push(`<section class="card cockpit-section" data-cockpit-section="notes"><h3>operator notes</h3>` +
|
|
19875
20119
|
view.notes.map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span> ${escape(one.note)}</p>`).join("\n") +
|
|
@@ -19883,8 +20127,41 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
|
|
|
19883
20127
|
},
|
|
19884
20128
|
};
|
|
19885
20129
|
}
|
|
19886
|
-
function completionForm(taskId, runId, digest, csrf) {
|
|
19887
|
-
|
|
20130
|
+
function completionForm(taskId, runId, digest, csrf, pullRequestTo = null) {
|
|
20131
|
+
const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="receipt" value="${escape(digest)}"><input type="hidden" name="run" value="${runId}">`;
|
|
20132
|
+
if (pullRequestTo !== null) {
|
|
20133
|
+
// With pull requests set up: the PR is the primary road, a bare completion the quiet one beside it.
|
|
20134
|
+
return `<form method="post" action="${taskHref(taskId)}/complete" class="card result-complete">${hidden}<p class="meta">After inspecting the work, mark this result complete. A pull request opens on ${escape(pullRequestTo)} from this exact commit.</p><div class="result-complete-actions"><button type="submit" name="publish" value="1" style="min-height:44px">Complete and open a pull request</button><button type="submit" class="secondary" style="min-height:44px">Complete only</button></div></form>`;
|
|
20135
|
+
}
|
|
20136
|
+
return `<form method="post" action="${taskHref(taskId)}/complete" class="card result-complete">${hidden}<p class="meta">After inspecting the work, mark this result complete. Checks stay unchanged; nothing is published or deployed.</p><button type="submit" style="min-height:44px">Mark complete</button></form>`;
|
|
20137
|
+
}
|
|
20138
|
+
/** The pull request, in one card: its state and link, one line of detail, and the one action it needs — Merge
|
|
20139
|
+
* behind the password when checks passed, or Open a pull request for a result completed without one. */
|
|
20140
|
+
function pullRequestCardHtml(pr, csrf) {
|
|
20141
|
+
if (pr.view === null) {
|
|
20142
|
+
if (pr.offer === null || csrf === "")
|
|
20143
|
+
return "";
|
|
20144
|
+
return `<section class="card pull-request" id="merge" data-pull-request="none"><div class="pull-request-head"><strong>No pull request</strong></div>` +
|
|
20145
|
+
`<p class="meta">This result was completed without one.</p>` +
|
|
20146
|
+
`<form method="post" action="${taskHref(pr.offer.taskId)}/complete" class="pull-request-act"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="receipt" value="${escape(pr.offer.digest)}"><input type="hidden" name="run" value="${pr.offer.runId}"><input type="hidden" name="publish" value="1">` +
|
|
20147
|
+
`<button type="submit" class="secondary">Open a pull request</button></form></section>`;
|
|
20148
|
+
}
|
|
20149
|
+
const view = pr.view;
|
|
20150
|
+
const url = safePrUrl(view.prUrl);
|
|
20151
|
+
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>`;
|
|
20152
|
+
const tone = view.state === "ready" || view.state === "merged" ? "ok" : view.state === "failing" || view.state === "failed" || view.state === "closed" ? "problem" : "waiting";
|
|
20153
|
+
const revision = view.revisionTask === null || view.state !== "failing" ? "" : ` <a href="${taskHref(view.revisionTask)}">Open revision</a>`;
|
|
20154
|
+
const merged = view.mergeCommit === null ? "" : `<p class="meta">Merge commit <span class="mono">${escape(view.mergeCommit.slice(0, 12))}</span></p>`;
|
|
20155
|
+
const target = pr.target === null ? "the base branch" : pr.target;
|
|
20156
|
+
const act = !view.canMerge || csrf === "" || view.prNumber === null ? "" :
|
|
20157
|
+
`<form method="post" action="${taskHref(pr.taskId)}/merge" class="pull-request-act" data-merge-form>` +
|
|
20158
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="run" value="${view.runId}">` +
|
|
20159
|
+
`<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>` +
|
|
20160
|
+
`<label>Password<input type="password" name="token" autocomplete="current-password" required></label>` +
|
|
20161
|
+
`<button type="submit">Merge</button></form>`;
|
|
20162
|
+
return `<section class="card pull-request pull-request--${tone}" id="merge" data-pull-request="${escape(view.state)}">` +
|
|
20163
|
+
`<div class="pull-request-head"><strong>${escape(view.label)}</strong>${link}</div>` +
|
|
20164
|
+
`<p class="meta">${escape(view.detail)}${revision}</p>${merged}${act}</section>`;
|
|
19888
20165
|
}
|
|
19889
20166
|
/** Exactly one primary road per result, chosen from its state; the
|
|
19890
20167
|
* others stay reachable from their own sections. Every form posts to the
|
|
@@ -21489,7 +21766,7 @@ function telegramDeliveryWords(store, bot) {
|
|
|
21489
21766
|
return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
|
|
21490
21767
|
return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
|
|
21491
21768
|
}
|
|
21492
|
-
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) {
|
|
21769
|
+
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) {
|
|
21493
21770
|
const permissionCard = permissionDefault === null
|
|
21494
21771
|
? ""
|
|
21495
21772
|
: [
|
|
@@ -21518,7 +21795,23 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
|
|
|
21518
21795
|
? ""
|
|
21519
21796
|
: `<p class="meta settings-changed">Changed ${escape(when(qualityDefault.updatedAt))}${qualityDefault.updatedBy === null ? "" : ` by ${escape(qualityDefault.updatedBy)}`}</p>`,
|
|
21520
21797
|
].join("\n");
|
|
21521
|
-
|
|
21798
|
+
// Quiet chat: this person's own choice. The installation's Telegram cadence only bundles Every step updates.
|
|
21799
|
+
const chatCard = chatNotices === null || csrf === ""
|
|
21800
|
+
? ""
|
|
21801
|
+
: [
|
|
21802
|
+
"<h3>Chat messages</h3>",
|
|
21803
|
+
`<form method="post" action="/settings/notifications" class="card" data-autosave>`,
|
|
21804
|
+
`<input type="hidden" name="csrf" value="${escape(csrf)}">`,
|
|
21805
|
+
`<label><input type="radio" name="mode" value="quiet"${chatNotices.mode === "quiet" ? " checked" : ""}> Only when I'm needed <span class="meta">· one message per task, updated as it moves</span></label>`,
|
|
21806
|
+
`<label><input type="radio" name="mode" value="all"${chatNotices.mode === "all" ? " checked" : ""}> Every step <span class="meta">· a new message for each update</span></label>`,
|
|
21807
|
+
`<label>Evening digest<select name="digest">` +
|
|
21808
|
+
digestTimes(chatNotices.digestAt).map(([value, label]) => `<option value="${value}"${value === (chatNotices.digestAt ?? "off") ? " selected" : ""}>${label}</option>`).join("") +
|
|
21809
|
+
`</select></label>`,
|
|
21810
|
+
`<p class="meta">One message: what finished, what waits, what failed.</p>`,
|
|
21811
|
+
`<button type="submit">Save</button>`,
|
|
21812
|
+
`</form>`,
|
|
21813
|
+
].join("\n");
|
|
21814
|
+
const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
|
|
21522
21815
|
? ""
|
|
21523
21816
|
: [
|
|
21524
21817
|
"<h3>Telegram digest</h3>",
|
|
@@ -21681,7 +21974,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
|
|
|
21681
21974
|
}),
|
|
21682
21975
|
services: messaging === null || messaging.configured.length === 0 ? null : { configured: messaging.configured, channel: messaging.channel, implicit: messaging.implicit },
|
|
21683
21976
|
push: push === null || csrf === "" ? null : { available: push.available, devices: push.devices.filter(one => one.retiredAt === null || one.retiredReason === "gone").map(one => ({ id: one.id, words: `${one.uaWords} · since ${when(one.createdAt)}`, state: one.retiredAt !== null ? "expired" : one.consecutiveFailures >= 20 ? "failing" : "ok", removable: one.retiredAt === null })) },
|
|
21684
|
-
|
|
21977
|
+
chat: chatNotices === null || csrf === "" ? null : chatNotices,
|
|
21978
|
+
digest: digest === null || csrf === "" || chatNotices?.mode === "quiet" ? null : { every: digest.everyMs === null ? "off" : String(Math.round(digest.everyMs / 60_000)), held: digest.everyMs === null ? null : `${digest.held} routine fact(s) held` },
|
|
21685
21979
|
telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
|
|
21686
21980
|
email,
|
|
21687
21981
|
workers,
|
|
@@ -21698,7 +21992,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
|
|
|
21698
21992
|
workersCard(workers),
|
|
21699
21993
|
updatesCard(updates, csrf),
|
|
21700
21994
|
firstResult === null ? "" : `<p class="meta" data-first-result>${escape(firstResult)}</p>`,
|
|
21701
|
-
pushCard === "" && messagingCard === "" && digestCard === "" ? "" : `<h2>Notifications</h2>`,
|
|
21995
|
+
pushCard === "" && messagingCard === "" && digestCard === "" && chatCard === "" ? "" : `<h2>Notifications</h2>`,
|
|
21996
|
+
chatCard,
|
|
21702
21997
|
messagingCard,
|
|
21703
21998
|
pushCard,
|
|
21704
21999
|
digestCard,
|
|
@@ -21794,6 +22089,7 @@ function settingsTiles() {
|
|
|
21794
22089
|
return `<nav class="settings-tiles" aria-label="Settings sections">${tiles.map(([href, label, icon]) => `<a href="${href}">${strokeIcon(icon)}<span>${label}</span></a>`).join("")}</nav>`;
|
|
21795
22090
|
}
|
|
21796
22091
|
const SETTINGS_TILE_ICONS = [
|
|
22092
|
+
["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
|
|
21797
22093
|
["/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"/>`],
|
|
21798
22094
|
["/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"/>`],
|
|
21799
22095
|
["/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"/>`],
|
|
@@ -21810,6 +22106,7 @@ const SETTINGS_TILE_ICONS = [
|
|
|
21810
22106
|
["/settings/approval", "Approval rules", `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>`],
|
|
21811
22107
|
["/settings/monitoring", "Monitoring", `<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`],
|
|
21812
22108
|
["/settings/retention", "Retention", `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`],
|
|
22109
|
+
["/settings/storage", "Storage", `<path d="M22 12H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M6 16h.01M10 16h.01"/>`],
|
|
21813
22110
|
["/settings/updates", "Updates", `<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>`],
|
|
21814
22111
|
["/settings/backups", "Backups", `<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>`],
|
|
21815
22112
|
["/settings/data", "Data", `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>`],
|