toolroll 0.9.54 → 0.9.56
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 +19 -0
- package/README.md +29 -12
- package/dist/action-ledger.d.ts +2 -4
- package/dist/action-ledger.js +2 -10
- package/dist/agent-onboard.js +4 -1
- package/dist/approval-policy.d.ts +5 -5
- package/dist/approval-policy.js +4 -4
- package/dist/approval-rules-ui.d.ts +3 -2
- package/dist/approval-rules-ui.js +17 -22
- package/dist/assignment-ui.d.ts +3 -2
- package/dist/assignment-ui.js +4 -11
- package/dist/backup-ui.d.ts +2 -1
- package/dist/backup-ui.js +16 -27
- package/dist/brand-mark.d.ts +3 -2
- package/dist/brand-mark.js +4 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-shell.d.ts +2 -1
- package/dist/browser-shell.js +9 -9
- package/dist/browser-workspace.d.ts +14 -15
- package/dist/browser-workspace.js +0 -3
- package/dist/budget-alerts.js +3 -3
- package/dist/chat-actions.d.ts +23 -18
- package/dist/chat-actions.js +110 -88
- package/dist/chat-ask.d.ts +2 -2
- package/dist/chat-ask.js +5 -5
- package/dist/chat-channel.d.ts +15 -15
- package/dist/chat-channel.js +20 -20
- package/dist/chat-controls.d.ts +0 -4
- package/dist/chat-controls.js +0 -1
- package/dist/chat-core.js +1 -1
- package/dist/chat-delivery-state.js +2 -2
- package/dist/chat-delivery.d.ts +2 -2
- package/dist/chat-delivery.js +27 -34
- package/dist/chat-flow.js +3 -3
- package/dist/chat-messages.d.ts +1 -1
- package/dist/chat-messages.js +4 -4
- package/dist/chat-migration.d.ts +1 -1
- package/dist/chat-migration.js +11 -11
- package/dist/chat-polish.d.ts +5 -4
- package/dist/chat-polish.js +9 -10
- package/dist/chat-rooms.js +3 -3
- package/dist/chat-warmth.d.ts +2 -2
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +26 -53
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +15 -10
- package/dist/coding-shipping-ui.d.ts +2 -1
- package/dist/coding-shipping-ui.js +6 -6
- package/dist/coding-ui.d.ts +3 -1
- package/dist/coding-ui.js +30 -29
- package/dist/contracts/chat-actions.d.ts +32 -27
- package/dist/contracts/chat-actions.js +29 -11
- package/dist/contracts/chat-content.d.ts +1 -1
- package/dist/contracts/chat-content.js +1 -1
- package/dist/contracts/chat-tables.js +4 -4
- package/dist/contracts/cli.d.ts +1 -3
- package/dist/contracts/console-api.d.ts +7 -7
- package/dist/contracts/console-api.js +13 -12
- package/dist/contracts/flow-propose.d.ts +3 -3
- package/dist/contracts/flow.d.ts +132 -125
- package/dist/contracts/flow.js +27 -12
- package/dist/contracts/health.d.ts +1 -3
- package/dist/contracts/lead-context.d.ts +1 -1
- package/dist/contracts/lead-context.js +2 -2
- package/dist/contracts/lead-tools.d.ts +20 -18
- package/dist/contracts/lead-tools.js +15 -15
- package/dist/contracts/stage-output.d.ts +1 -1
- package/dist/contracts/stage-output.js +1 -1
- package/dist/contracts/store-json.d.ts +23 -22
- package/dist/contracts/store-json.js +17 -17
- package/dist/contracts/{teammate-turn.d.ts → subagent-turn.d.ts} +5 -5
- package/dist/contracts/{teammate-turn.js → subagent-turn.js} +7 -7
- package/dist/control-ui.d.ts +5 -4
- package/dist/control-ui.js +9 -34
- package/dist/converse.d.ts +23 -23
- package/dist/converse.js +28 -28
- package/dist/coordinator-proposals.d.ts +2 -2
- package/dist/coordinator-proposals.js +1 -1
- package/dist/coordinator.js +1 -1
- package/dist/credentials-ui.d.ts +5 -4
- package/dist/credentials-ui.js +23 -24
- package/dist/demo-chat.d.ts +3 -3
- package/dist/demo-chat.js +29 -76
- package/dist/deprecations.d.ts +12 -0
- package/dist/deprecations.js +20 -0
- package/dist/desktop-update-gate.js +8 -4
- package/dist/desktop-update.js +6 -1
- package/dist/discord-chat.js +6 -6
- package/dist/discord-settings.d.ts +3 -2
- package/dist/discord-settings.js +17 -42
- package/dist/dispatch.d.ts +1 -1
- package/dist/dispatch.js +1 -1
- package/dist/envelope.js +10 -1
- package/dist/evidence-pack.d.ts +2 -1
- package/dist/evidence-pack.js +45 -49
- package/dist/export-ui.d.ts +7 -2
- package/dist/export-ui.js +4 -10
- package/dist/export.js +7 -7
- package/dist/flow-engine.d.ts +2 -2
- package/dist/flow-engine.js +21 -21
- package/dist/flow-gallery-ui.d.ts +4 -3
- package/dist/flow-gallery-ui.js +28 -43
- package/dist/flow-insights.d.ts +1 -1
- package/dist/flow-share.js +13 -8
- package/dist/flow-starters-ui.d.ts +2 -1
- package/dist/flow-starters-ui.js +12 -15
- package/dist/flow-steps.d.ts +5 -5
- package/dist/flow-steps.js +19 -19
- package/dist/flow-triggers.d.ts +2 -1
- package/dist/flow-triggers.js +17 -15
- package/dist/flows-cli.js +1 -1
- package/dist/flows-ui.d.ts +4 -3
- package/dist/flows-ui.js +38 -49
- package/dist/flows.js +21 -20
- package/dist/guides.js +112 -6
- package/dist/html.d.ts +76 -0
- package/dist/html.js +173 -0
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +19 -23
- package/dist/invoke.js +1 -1
- package/dist/kits-ui.d.ts +5 -4
- package/dist/kits-ui.js +22 -24
- package/dist/kits.d.ts +12 -12
- package/dist/kits.js +31 -31
- package/dist/knowledge-ui.d.ts +6 -5
- package/dist/knowledge-ui.js +26 -25
- package/dist/{mate-cli.d.ts → lead-cli.d.ts} +11 -11
- package/dist/{mate-cli.js → lead-cli.js} +38 -38
- package/dist/lead-commitments.js +4 -4
- package/dist/lead-context.d.ts +12 -2
- package/dist/lead-context.js +15 -17
- package/dist/{mate-contract.d.ts → lead-contract.d.ts} +3 -3
- package/dist/{mate-contract.js → lead-contract.js} +7 -6
- package/dist/{mate-doors.d.ts → lead-doors.d.ts} +6 -6
- package/dist/{mate-doors.js → lead-doors.js} +24 -24
- package/dist/lead-follow.d.ts +5 -5
- package/dist/lead-follow.js +9 -9
- package/dist/lead-people.d.ts +5 -5
- package/dist/lead-people.js +14 -14
- package/dist/{mate-progress.d.ts → lead-progress.d.ts} +9 -9
- package/dist/{mate-progress.js → lead-progress.js} +3 -3
- package/dist/{mate-tools.d.ts → lead-tools.d.ts} +19 -19
- package/dist/{mate-tools.js → lead-tools.js} +109 -101
- package/dist/{mate.d.ts → lead.d.ts} +27 -27
- package/dist/{mate.js → lead.js} +102 -102
- package/dist/ledger-view.d.ts +2 -1
- package/dist/ledger-view.js +27 -29
- package/dist/limits-ui.d.ts +3 -2
- package/dist/limits-ui.js +8 -8
- package/dist/live-bus.d.ts +50 -8
- package/dist/live-bus.js +133 -50
- package/dist/mcp-connect.d.ts +1 -1
- package/dist/mcp-connect.js +3 -3
- package/dist/mcp-core.js +1 -1
- package/dist/mcp-oauth.js +35 -36
- package/dist/memory-pass.d.ts +2 -2
- package/dist/memory-pass.js +7 -7
- package/dist/models-ui.d.ts +2 -1
- package/dist/models-ui.js +25 -35
- package/dist/monitoring-ui.d.ts +4 -3
- package/dist/monitoring-ui.js +13 -26
- package/dist/notification-rows.d.ts +2 -2
- package/dist/openrouter-models.d.ts +2 -1
- package/dist/openrouter-models.js +5 -9
- package/dist/operate.d.ts +3 -3
- package/dist/operate.js +46 -41
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +13 -13
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +17 -23
- package/dist/policy.d.ts +1 -1
- package/dist/principal.d.ts +1 -1
- package/dist/principal.js +2 -2
- package/dist/project-delete-ui.d.ts +4 -3
- package/dist/project-delete-ui.js +16 -28
- package/dist/project-delete.d.ts +1 -1
- package/dist/project-delete.js +26 -26
- package/dist/project-memory.js +1 -1
- package/dist/project-tools.d.ts +1 -1
- package/dist/project-tools.js +1 -1
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +17 -32
- package/dist/recipe-ui.d.ts +8 -7
- package/dist/recipe-ui.js +39 -32
- package/dist/release-adapters.d.ts +60 -0
- package/dist/release-adapters.js +435 -0
- package/dist/release-cli.d.ts +24 -0
- package/dist/release-cli.js +181 -0
- package/dist/release-gate.d.ts +80 -0
- package/dist/release-gate.js +150 -0
- package/dist/release-links.d.ts +29 -0
- package/dist/release-links.js +102 -0
- package/dist/release.d.ts +331 -0
- package/dist/release.js +550 -0
- package/dist/reply-shape.d.ts +2 -1
- package/dist/reply-shape.js +5 -5
- package/dist/repository-context-ui.d.ts +2 -1
- package/dist/repository-context-ui.js +2 -7
- package/dist/request-budget-ui.d.ts +2 -1
- package/dist/request-budget-ui.js +16 -16
- package/dist/result-review.d.ts +2 -3
- package/dist/result-review.js +3 -9
- package/dist/retention-ui.d.ts +7 -1
- package/dist/retention-ui.js +7 -11
- package/dist/retention.js +4 -4
- package/dist/scope.d.ts +2 -2
- package/dist/scout-report.d.ts +1 -1
- package/dist/scout-report.js +1 -1
- package/dist/serve.d.ts +11 -2
- package/dist/serve.js +105 -99
- package/dist/server/chat.d.ts +7 -10
- package/dist/server/chat.js +984 -1021
- package/dist/server/chrome.d.ts +291 -0
- package/dist/server/chrome.js +747 -0
- package/dist/server/console.css +2819 -0
- package/dist/server/flows.d.ts +2 -6
- package/dist/server/flows.js +854 -773
- package/dist/server/guards.d.ts +1 -1
- package/dist/server/guards.js +8 -5
- package/dist/server/handler-context.d.ts +1 -1
- package/dist/server/handler-context.js +1 -1
- package/dist/server/handler-registry.d.ts +8 -6
- package/dist/server/handler-registry.js +14 -2
- package/dist/server/http.d.ts +274 -0
- package/dist/server/http.js +239 -0
- package/dist/server/live.d.ts +34 -0
- package/dist/server/live.js +309 -0
- package/dist/server/pages.d.ts +2 -6
- package/dist/server/pages.js +808 -789
- package/dist/server/people-tokens.d.ts +1 -6
- package/dist/server/people-tokens.js +581 -573
- package/dist/server/remote.d.ts +4 -7
- package/dist/server/remote.js +39 -70
- package/dist/server/render-chat.d.ts +402 -0
- package/dist/server/render-chat.js +927 -0
- package/dist/server/render-pages.d.ts +535 -0
- package/dist/server/render-pages.js +1277 -0
- package/dist/server/render-people.d.ts +32 -0
- package/dist/server/render-people.js +121 -0
- package/dist/server/render-results.d.ts +744 -0
- package/dist/server/render-results.js +2237 -0
- package/dist/server/render-settings.d.ts +154 -0
- package/dist/server/render-settings.js +580 -0
- package/dist/server/render-tasks.d.ts +632 -0
- package/dist/server/render-tasks.js +2345 -0
- package/dist/server/request-context.d.ts +46 -0
- package/dist/server/request-context.js +17 -0
- package/dist/server/route-table.d.ts +25 -3
- package/dist/server/route-table.js +167 -77
- package/dist/server/runtime.d.ts +28 -20
- package/dist/server/runtime.js +7 -1
- package/dist/server/session.d.ts +138 -0
- package/dist/server/session.js +94 -0
- package/dist/server/settings.d.ts +1 -5
- package/dist/server/settings.js +2186 -2121
- package/dist/server/task-views.d.ts +11 -4
- package/dist/server/task-views.js +26 -22
- package/dist/server/tasks.d.ts +9 -9
- package/dist/server/tasks.js +1489 -1420
- package/dist/server-telemetry.d.ts +4 -3
- package/dist/server-telemetry.js +4 -8
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.d.ts +4 -3
- package/dist/skills-ui.js +14 -17
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +2 -1
- package/dist/slack-chat.d.ts +2 -2
- package/dist/slack-chat.js +6 -6
- package/dist/slack-settings.d.ts +3 -2
- package/dist/slack-settings.js +17 -42
- package/dist/spend-ui.d.ts +5 -4
- package/dist/spend-ui.js +17 -40
- package/dist/spend.d.ts +14 -14
- package/dist/spend.js +31 -31
- package/dist/sso-ui.d.ts +3 -2
- package/dist/sso-ui.js +26 -27
- package/dist/storage-ui.d.ts +2 -1
- package/dist/storage-ui.js +14 -33
- package/dist/store-baseline.d.ts +19 -0
- package/dist/store-baseline.js +2873 -0
- package/dist/store.d.ts +197 -249
- package/dist/store.js +747 -3084
- package/dist/subagent-admin.d.ts +53 -0
- package/dist/{teammate-admin.js → subagent-admin.js} +37 -37
- package/dist/{teammate-desk.d.ts → subagent-desk.d.ts} +22 -23
- package/dist/{teammate-desk.js → subagent-desk.js} +26 -35
- package/dist/{teammate-memory.d.ts → subagent-memory.d.ts} +17 -17
- package/dist/{teammate-memory.js → subagent-memory.js} +41 -41
- package/dist/{teammate-question.d.ts → subagent-question.d.ts} +6 -6
- package/dist/{teammate-question.js → subagent-question.js} +17 -17
- package/dist/{teammate-tools.d.ts → subagent-tools.d.ts} +18 -18
- package/dist/{teammate-tools.js → subagent-tools.js} +36 -36
- package/dist/{teammate-week.d.ts → subagent-week.d.ts} +10 -10
- package/dist/{teammate-week.js → subagent-week.js} +23 -23
- package/dist/{teammate-work.d.ts → subagent-work.d.ts} +21 -21
- package/dist/{teammate-work.js → subagent-work.js} +50 -50
- package/dist/subagents-ui.d.ts +22 -0
- package/dist/subagents-ui.js +137 -0
- package/dist/{teammates.d.ts → subagents.d.ts} +11 -11
- package/dist/{teammates.js → subagents.js} +21 -21
- package/dist/subscription-chat.d.ts +13 -13
- package/dist/subscription-chat.js +19 -19
- package/dist/surface.d.ts +19 -2
- package/dist/surface.js +20 -33
- package/dist/task-composer.d.ts +2 -1
- package/dist/task-composer.js +7 -7
- package/dist/task-status.d.ts +4 -3
- package/dist/task-status.js +15 -16
- package/dist/team-cli.d.ts +1 -0
- package/dist/team-cli.js +14 -2
- package/dist/team-http.d.ts +5 -9
- package/dist/team-http.js +5 -63
- package/dist/team-leads.js +13 -13
- package/dist/team-runtime.d.ts +2 -2
- package/dist/team-runtime.js +8 -8
- package/dist/team-ui.d.ts +2 -1
- package/dist/team-ui.js +2 -5
- package/dist/teams-chat.js +6 -6
- package/dist/teams-settings.d.ts +3 -2
- package/dist/teams-settings.js +20 -31
- package/dist/telegram-flow.d.ts +3 -3
- package/dist/telegram-flow.js +3 -3
- package/dist/{telegram-mate.d.ts → telegram-lead.d.ts} +5 -5
- package/dist/{telegram-mate.js → telegram-lead.js} +27 -27
- package/dist/telegram-settings.d.ts +3 -2
- package/dist/telegram-settings.js +14 -25
- package/dist/telegram-status.js +1 -1
- package/dist/telegram-team.d.ts +1 -1
- package/dist/telegram-team.js +5 -5
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +9 -30
- package/dist/text-limits.d.ts +18 -18
- package/dist/text-limits.js +18 -18
- package/dist/toolroll-update-ui.d.ts +3 -2
- package/dist/toolroll-update-ui.js +35 -39
- package/dist/toolroll-update.d.ts +8 -0
- package/dist/toolroll-update.js +45 -8
- package/dist/tools-ui.d.ts +5 -2
- package/dist/tools-ui.js +31 -31
- package/dist/when-html.d.ts +11 -3
- package/dist/when-html.js +5 -6
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +10 -10
- package/package.json +1 -2
- package/dist/server/shared.d.ts +0 -3151
- package/dist/server/shared.js +0 -12001
- package/dist/teammate-admin.d.ts +0 -53
- package/dist/teammates-ui.d.ts +0 -20
- package/dist/teammates-ui.js +0 -166
package/dist/server/tasks.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { handlersOf } from './handler-registry.js';
|
|
2
|
+
import { DEPRECATED_PAGE } from "../deprecations.js";
|
|
1
3
|
/** tasks handlers, moved without changing their route bodies. */
|
|
4
|
+
import { html, htmlString, joinHtml, postForm, textHtml } from "../html.js";
|
|
2
5
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
6
|
import { lstatSync, realpathSync } from "node:fs";
|
|
4
7
|
import {} from "node:http";
|
|
@@ -36,7 +39,7 @@ import { fileAddTestsTask, requestFollowUpChecks } from '../result-follow-ups.js
|
|
|
36
39
|
import { commentSourceKey, isRevisionFeedback, parseResultTab, resultReturnTarget } from "../result-review.js";
|
|
37
40
|
import { isAlive as runnerAlive } from "../runner.js";
|
|
38
41
|
import { acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, hasFreshIdentitySignIn, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
|
|
39
|
-
import {
|
|
42
|
+
import { subagentNames as subagentNamesOf } from "../spend.js";
|
|
40
43
|
import { verifiedAuthor } from "../store.js";
|
|
41
44
|
import { runCostWords } from "../summary.js";
|
|
42
45
|
import { requestTaskStop, resumeTaskStop } from "../task-control.js";
|
|
@@ -44,1395 +47,1442 @@ import { templateByName } from "../templates.js";
|
|
|
44
47
|
import { TEXT_LIMITS } from "../text-limits.js";
|
|
45
48
|
import { WorkIndexCursorError, workIndexPage } from "../work-index.js";
|
|
46
49
|
import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
|
|
47
|
-
import {
|
|
50
|
+
import { requestContext } from "./request-context.js";
|
|
51
|
+
import { oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
|
|
52
|
+
import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
|
|
53
|
+
import { boardBody, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, tasksPage, workPage } from "./render-pages.js";
|
|
54
|
+
import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
|
|
55
|
+
import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
|
|
56
|
+
import {} from "./session.js";
|
|
48
57
|
export function createTasksHandlers(runtime) {
|
|
49
|
-
const { store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return respond(response, 200, 'application/json', JSON.stringify(
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
? respond(response, 403, 'application/json', JSON.stringify({ ok: false, error: 'Your access changed. Sign in again.' }))
|
|
89
|
-
: refuse(response, who, 403, 'Your access changed. Sign in again.', '/work');
|
|
90
|
-
const message = error instanceof Error ? error.message : 'The coding workspace is unavailable.';
|
|
91
|
-
if (match?.[2] === 'ship')
|
|
92
|
-
return sendScreen(response, 409, screen('Review for shipping', `<h1>Prepare this result</h1><p>${escape(message)}</p><a href="/code/${match[1]}">Back to coding</a>`, { chrome: chromeFor(project, 'code') }));
|
|
93
|
-
if (match?.[2])
|
|
94
|
-
return respond(response, 409, 'application/json', JSON.stringify({ ok: false, error: message }));
|
|
95
|
-
return sendScreen(response, 404, screen('Coding session unavailable', `<h1>Session unavailable</h1><p>${escape(message)}</p><a href="/code">Open coding workspace</a>`, { chrome: chromeFor(project, 'code') }));
|
|
96
|
-
}
|
|
58
|
+
const { store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult, leadPrincipal, familyOf, armTaskResume, runIsLive, revisionLedgerOf, resultDetailOf, reviewFactsFor, taskRepoOf, runVisible, restricted, codingActorAllowed, codingProjectAllowed, managedRepos, routeViewOf, workAccess, firstRunStepsNow, revisionDocOf, failureOf, dockedConversation, planContractViewOf, familiesInView, explainAttempt, taskChatFocus, familyTasksInView, taskRooms, identify, liveCeiling, projectOf, ceiling, bustBadge, revisionDestination } = runtime;
|
|
59
|
+
/** A row admitted the address but its path parameter does not parse: the console's unknown page. */
|
|
60
|
+
function noPage(who, response) {
|
|
61
|
+
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
62
|
+
}
|
|
63
|
+
const showCode = (part) => async (ctx) => {
|
|
64
|
+
const { url, who, response, project } = ctx;
|
|
65
|
+
if (who.via !== 'cookie' || !codingActorAllowed({ name: who.name, generation: who.session.generation }))
|
|
66
|
+
return refuse(response, who, 403, 'Coding sessions require installation-wide operator access. You can manage project tasks in Work.', '/work');
|
|
67
|
+
const actor = { name: who.name, generation: who.session.generation };
|
|
68
|
+
const sessionId = part === "workspace" || part === "unknown" ? null : /^\/code\/([a-f0-9]{32})/.exec(url.pathname)[1];
|
|
69
|
+
const read = part === "state" || part === "changes" ? part : null;
|
|
70
|
+
try {
|
|
71
|
+
const selected = sessionId !== null && runtime.coding ? runtime.coding.get(sessionId, actor) : null;
|
|
72
|
+
if (selected && !codingProjectAllowed(selected.repo))
|
|
73
|
+
return refuse(response, who, 403, 'That project is outside your access.', '/projects');
|
|
74
|
+
if (part === 'ship' && selected) {
|
|
75
|
+
const preview = codingHandoffPreview(store, { sessionId: selected.id, actor: who.name });
|
|
76
|
+
return sendScreen(response, 200, screen('Review for shipping', codingShippingHtml(preview), { chrome: chromeFor(selected.repo, 'code') }));
|
|
77
|
+
}
|
|
78
|
+
if (part === 'ship' || read !== null) {
|
|
79
|
+
if (!selected || !runtime.coding)
|
|
80
|
+
throw Error('The coding session is unavailable.');
|
|
81
|
+
if (read === 'state' && url.searchParams.get('revision') === String(runtime.coding.revision(selected.id, actor)))
|
|
82
|
+
return respond(response, 200, 'application/json', JSON.stringify({ unchanged: true }));
|
|
83
|
+
const result = read === 'changes' ? await runtime.coding.changes(selected.id, actor) : runtime.coding.snapshot(selected.id, actor);
|
|
84
|
+
if (!codingActorAllowed(actor) || !codingProjectAllowed(selected.repo))
|
|
85
|
+
return respond(response, 403, 'application/json', JSON.stringify({ ok: false, error: 'Your access changed. Sign in again.' }));
|
|
86
|
+
return respond(response, 200, 'application/json', JSON.stringify(result));
|
|
87
|
+
}
|
|
88
|
+
if (part === "unknown")
|
|
89
|
+
return refuse(response, who, 404, 'Coding session not found.', '/code');
|
|
90
|
+
const requestedProject = url.searchParams.get('project');
|
|
91
|
+
if (requestedProject !== null && (!visible(requestedProject) || ![...managedRepos(), ...store.listProjects().map(p => p.path)].includes(requestedProject)))
|
|
92
|
+
return refuse(response, who, 403, 'That project is outside your access.', '/projects');
|
|
93
|
+
const codeProject = selected?.repo ?? requestedProject ?? project;
|
|
94
|
+
const chrome = chromeFor(codeProject, 'code');
|
|
95
|
+
const content = codingWorkspaceHtml({ owner: who.name, projects: chrome.projects ?? [], sessions: runtime.coding?.list(actor).filter(s => codingProjectAllowed(s.repo)) ?? [], selected: selected && runtime.coding ? runtime.coding.snapshot(selected.id, actor) : null, csrf: who.session.csrf, project: codeProject, available: runtime.coding !== null, ...(runtime.codingProblem ? { error: runtime.codingProblem } : {}) });
|
|
96
|
+
return sendScreen(response, 200, screen('Code', html `<p class="problem" role="status" data-deprecated="session">${DEPRECATED_PAGE.session}</p>${content}`, { chrome, functional: { script: codingWorkspaceScript(), fetches: true } }));
|
|
97
97
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (!codingActorAllowed(actor))
|
|
100
|
+
return read !== null
|
|
101
|
+
? respond(response, 403, 'application/json', JSON.stringify({ ok: false, error: 'Your access changed. Sign in again.' }))
|
|
102
|
+
: refuse(response, who, 403, 'Your access changed. Sign in again.', '/work');
|
|
103
|
+
const message = error instanceof Error ? error.message : 'The coding workspace is unavailable.';
|
|
104
|
+
if (part === 'ship')
|
|
105
|
+
return sendScreen(response, 409, screen('Review for shipping', html `<h1>Prepare this result</h1><p>${message}</p><a href="/code/${sessionId}">Back to coding</a>`, { chrome: chromeFor(project, 'code') }));
|
|
106
|
+
if (read !== null)
|
|
107
|
+
return respond(response, 409, 'application/json', JSON.stringify({ ok: false, error: message }));
|
|
108
|
+
return sendScreen(response, 404, screen('Coding session unavailable', html `<h1>Session unavailable</h1><p>${message}</p><a href="/code">Open coding workspace</a>`, { chrome: chromeFor(project, 'code') }));
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
async function showInbox(ctx) {
|
|
112
|
+
const { url, who, request, response, now, project } = ctx;
|
|
113
|
+
// With no project open in scoped mode this is the ROLL-UP inbox:
|
|
114
|
+
// admission binds inside the bounded queries, every row's repo is
|
|
115
|
+
// re-proved here, and rows render as links only (Codex roll-up
|
|
116
|
+
// review, findings 6–8). Gaps stay per-project — they are derived
|
|
117
|
+
// against one repo's capabilities and roll up dishonestly.
|
|
118
|
+
const rollup = project === null && !unscopedMode;
|
|
119
|
+
const admission = rollup ? admissionList() : null;
|
|
120
|
+
const cancelled = store
|
|
121
|
+
.listCancelledBlockersScoped(project, 10, admission)
|
|
122
|
+
.filter(one => visible(one.repo) && visible(one.blockerRepo));
|
|
123
|
+
const inboxData = {
|
|
124
|
+
csrf: who.via === "cookie" ? who.session.csrf : "",
|
|
125
|
+
revision: who.via === "cookie" ? who.session.projectRevision : 0,
|
|
126
|
+
rollup,
|
|
127
|
+
interactive: project !== null,
|
|
128
|
+
decisions: store.listDecisionsScoped(project).filter(one => visible(one.repo)).slice(0, 10),
|
|
129
|
+
// Each waiting scope wears its consent door (v48 authority repair): a scope whose
|
|
130
|
+
// approval is closed — unreadable route, a route that cannot run,
|
|
131
|
+
// a pre-routing row whose approval lapsed — reads as needing
|
|
132
|
+
// attention, never as something to approve.
|
|
133
|
+
approvals: store.scopesAwaitingApproval(project, 10, admission).filter(one => visible(one.repo)).map(one => {
|
|
134
|
+
const ref = store.lookupRef(one.taskId);
|
|
135
|
+
const scope = store.getScope(one.taskId);
|
|
136
|
+
const door = consentDoorOf(scope, routeViewOf(one.taskId, ref, scope, now, who));
|
|
137
|
+
return { ...one, closed: door.open ? null : door.title };
|
|
138
|
+
}),
|
|
139
|
+
requeueables: store.listRequeueablesScoped(project, now, 10, admission).filter(one => visible(one.repo)),
|
|
140
|
+
needsVerification: store
|
|
141
|
+
.listCompletedWorkScoped(project, 10, admission)
|
|
142
|
+
.filter(one => visible(one.repo) && (one.proofVerdict === "short" || one.proofVerdict === "refuted") && !one.proofAccepted &&
|
|
143
|
+
assignmentOf(store, one.taskId, now, workAccess(), evidenceRoot) === null)
|
|
144
|
+
.map(one => ({
|
|
145
|
+
taskId: one.taskId,
|
|
146
|
+
title: one.title,
|
|
147
|
+
verdict: one.proofVerdict,
|
|
148
|
+
repo: one.repo,
|
|
149
|
+
matrix: one.proofMatrix,
|
|
150
|
+
repairChain: one.runId === null ? null : store.repairChainFor(one.runId),
|
|
151
|
+
})),
|
|
152
|
+
cancelledBlockers: cancelled,
|
|
153
|
+
gaps: project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10),
|
|
154
|
+
wizard: firstRunStepsNow(now),
|
|
155
|
+
worker: (() => {
|
|
156
|
+
// The one fact the inbox must never hide (install review): with
|
|
157
|
+
// no worker answering, nothing here will ever build, and every
|
|
158
|
+
// approval below is a promise nobody is there to keep.
|
|
159
|
+
const runners = store.listRunners().filter(one => one.retiredAt === null && (!restricted() || one.repos.some(visible)));
|
|
160
|
+
const answering = runners.filter(one => runnerAlive(one, now));
|
|
161
|
+
const lastHeard = runners.map(one => one.heartbeatAt).sort().at(-1) ?? null;
|
|
162
|
+
return { answering: answering.length, registered: runners.length, lastHeard };
|
|
163
|
+
})(),
|
|
164
|
+
now,
|
|
165
|
+
// Console v2: results ready to review and work running now, from the same admitted work index.
|
|
166
|
+
...(() => {
|
|
167
|
+
const row = (one) => ({ taskId: one.rootId, title: one.title, detail: one.status.detail, repo: one.repo });
|
|
168
|
+
const needsYou = workIndexPage(store, now, workAccess(), { view: "needs-you", limit: 40, project });
|
|
169
|
+
const runningNow = workIndexPage(store, now, workAccess(), { view: "running", limit: 40, project });
|
|
170
|
+
return { ready: needsYou.items.filter(one => one.assignmentState === "ready-to-check").map(row), running: runningNow.items.map(row) };
|
|
171
|
+
})(),
|
|
172
|
+
};
|
|
173
|
+
// Which tabs hold something new since this browser last looked (the dots on a phone).
|
|
174
|
+
const tab = parseInboxTab(url.searchParams.get("tab")) ?? "all";
|
|
175
|
+
const prints = inboxFingerprints(inboxData);
|
|
176
|
+
const seenWords = /(?:^|;\s*)so-inbox-seen=([0-9a-f]{8}(?:\.[0-9a-f]{8}){3})(?:;|$)/.exec(request.headers.cookie ?? "")?.[1]?.split(".") ?? null;
|
|
177
|
+
const order = ["needs-you", "ready", "running", "all"];
|
|
178
|
+
const seen = Object.fromEntries(order.map((one, index) => [one, seenWords?.[index] ?? ""]));
|
|
179
|
+
const unread = order.filter(one => seenWords !== null && seen[one] !== prints[one]);
|
|
180
|
+
for (const one of order)
|
|
181
|
+
if (tab === "all" || one === tab)
|
|
182
|
+
seen[one] = prints[one];
|
|
183
|
+
response.setHeader("Set-Cookie", `so-inbox-seen=${order.map(one => seen[one] || prints[one]).join(".")}; SameSite=Lax; Path=/; Max-Age=31536000`);
|
|
184
|
+
return sendScreen(response, 200, inboxPage(chromeFor(project, "inbox"), { ...inboxData, tab, unread }));
|
|
185
|
+
}
|
|
186
|
+
async function showWork(ctx) {
|
|
187
|
+
const { url, who, response, now, project } = ctx;
|
|
188
|
+
// The Work destination (workspace package 1): every task in view as
|
|
189
|
+
// one row wearing the shared status projection, with All, Needs you,
|
|
190
|
+
// Running, and Completed as shortcuts over the same rows — never a
|
|
191
|
+
// persisted state. With no project open in scoped mode this rolls
|
|
192
|
+
// up like the inbox: admission binds the query and every row's repo
|
|
193
|
+
// is re-proved here.
|
|
194
|
+
const view = parseWorkView(url.searchParams.get("view"));
|
|
195
|
+
const rollup = project === null && !unscopedMode;
|
|
196
|
+
let work;
|
|
197
|
+
try {
|
|
198
|
+
work = workIndexPage(store, now, workAccess(), { view, limit: 40, cursor: url.searchParams.get('cursor'), project });
|
|
199
|
+
const facts = requestContext.getStore();
|
|
200
|
+
if (facts !== undefined) {
|
|
201
|
+
// These counts cover the exact admitted project lens, including
|
|
202
|
+
// unplaced tasks where permitted. Crew excludes unplaced rows.
|
|
203
|
+
if (project === null)
|
|
204
|
+
facts.workCounts = work.projects;
|
|
205
|
+
const admitted = workAccess().repos;
|
|
206
|
+
const crewLens = project !== null || admitted !== null && JSON.stringify([...admitted].sort()) === JSON.stringify(managedRepos().sort()) && !work.projects.some(one => one.repo === null);
|
|
207
|
+
if (view === 'all' && !url.searchParams.has('cursor') && crewLens)
|
|
208
|
+
facts.workCrew = { project, page: work };
|
|
200
209
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
if (item.state !== "failed")
|
|
218
|
-
return item;
|
|
219
|
-
const family = familyOf(item.activeTaskId);
|
|
220
|
-
if (family === null || family.problem !== null)
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error instanceof WorkIndexCursorError)
|
|
213
|
+
return refuse(response, who, 400, 'This task page has expired. Open the first page.', '/work');
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
// A failed row says what its latest attempt missed, as its task page does (a stop reason already reads so), from
|
|
217
|
+
// the database alone: a row reads no check log. A live build's row says which step it is on, or the step it is
|
|
218
|
+
// stuck on, from that live attempt's own progress only, never a stopped attempt's; the step's words come from its
|
|
219
|
+
// current plan (one saved file, for live rows only), and without it the row names the step by number.
|
|
220
|
+
work = { ...work, items: work.items.map((item) => {
|
|
221
|
+
if (item.liveRunId !== null) {
|
|
222
|
+
const ref = store.lookupRef(item.activeTaskId);
|
|
223
|
+
const recorded = ref === null ? null : store.latestCheckpointForRun(item.liveRunId);
|
|
224
|
+
if (ref === null || recorded === null || recorded.taskRef !== ref.id)
|
|
221
225
|
return item;
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
226
|
+
const plan = store.currentPlanRevision(ref.id);
|
|
227
|
+
const parsed = plan === null ? null : (() => { const doc = revisionDocOf(plan); return doc === null ? null : parseExecutionPlanDocument(doc.document); })();
|
|
228
|
+
const words = new Map(parsed?.ok === true ? milestonesOf(parsed.document).map(one => [one.id, one.description]) : []);
|
|
229
|
+
const steps = buildProgressOf(recorded.snapshot.milestones.map(one => ({ description: words.get(one.id) ?? null, state: one.state, note: one.note ?? null })));
|
|
230
|
+
return steps === null ? item : { ...item, progress: steps.line };
|
|
231
|
+
}
|
|
232
|
+
if (item.state !== "failed")
|
|
233
|
+
return item;
|
|
234
|
+
const family = familyOf(item.activeTaskId);
|
|
235
|
+
if (family === null || family.problem !== null)
|
|
236
|
+
return item;
|
|
237
|
+
const failure = failureOf(family.versions.flatMap(version => store.runsFor(version.refId)), null, false);
|
|
238
|
+
return failure.kind === "reason" ? item : { ...item, status: { ...item.status, detail: failure.line } };
|
|
239
|
+
}) };
|
|
240
|
+
const page = workPage(chromeFor(project, "work", undefined, rollup ? "all" : undefined), {
|
|
241
|
+
view,
|
|
242
|
+
...(url.searchParams.has('project') && project !== null ? { projectFilter: project } : {}),
|
|
243
|
+
work,
|
|
244
|
+
previous: url.searchParams.has('cursor'),
|
|
245
|
+
multiProject: new Set(work.items.map(task => task.repo ?? "")).size > 1,
|
|
246
|
+
now,
|
|
247
|
+
// v105: subscription windows and monthly budgets, for whoever runs the installation.
|
|
248
|
+
limits: store.isInstanceOperator(who.name)
|
|
249
|
+
? limitsView(store.providerLimits(), store.budgets().length === 0 ? [] : store.monthSpendCached(now).budgets, { project: projectName, subagent: id => subagentNamesOf(store.handle).get(id) ?? `Subagent ${id}` }, now)
|
|
250
|
+
: null,
|
|
251
|
+
});
|
|
252
|
+
// One project's Tasks dock that project's own conversation (v77).
|
|
253
|
+
const projectThread = url.searchParams.has('project') && project !== null && managedRepos().includes(project) ? project : null;
|
|
254
|
+
const docked = projectThread === null ? null : dockedConversation(who, null, projectThread, now, `/work?project=${encodeURIComponent(projectThread)}`);
|
|
255
|
+
if (docked !== null)
|
|
256
|
+
page.workspace = { ...page.workspace, conversation: docked, pageHtml: page.body };
|
|
257
|
+
// Without a docked chat the list reads itself: 10 s while any of its tasks is building, else 30 s.
|
|
258
|
+
page.refreshSeconds = work.totals.running > 0 ? 10 : 30;
|
|
259
|
+
return sendScreen(response, 200, page);
|
|
260
|
+
}
|
|
261
|
+
async function showNext(ctx) {
|
|
262
|
+
const { url, who, response, now, project } = ctx;
|
|
263
|
+
// Triage: everything waiting on a person, one at a time, hardest-
|
|
264
|
+
// blocked first — oldest question, then plans and scopes to approve,
|
|
265
|
+
// then stalled work to retry, then requirement gaps. `skip` is a
|
|
266
|
+
// bounded, session-free cursor of keys the operator set aside; every
|
|
267
|
+
// act 303s back here, which is what makes it a flow and not a list.
|
|
268
|
+
const skipped = new Set((url.searchParams.get("skip") ?? "").split(",").filter(one => /^[darg]:[A-Za-z0-9._:-]{1,80}$/.test(one)).slice(0, 20));
|
|
269
|
+
const decisions = store.listDecisionsScoped(project).filter(one => one.state !== "answered");
|
|
270
|
+
const approvals = store.scopesAwaitingApproval(project, 20);
|
|
271
|
+
const requeueables = store.listRequeueablesScoped(project, now, 20);
|
|
272
|
+
const gaps = project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0);
|
|
273
|
+
const queue = [
|
|
274
|
+
...decisions.map(decision => ({ key: `d:${decision.id}`, kind: "decision", decision })),
|
|
275
|
+
...approvals.map(approval => ({ key: `a:${approval.taskId}`, kind: "approval", approval })),
|
|
276
|
+
...requeueables.map(stalled => ({ key: `r:${stalled.taskId}`, kind: "requeue", stalled })),
|
|
277
|
+
...gaps.map(gap => ({ key: `g:${gap.key.replace(/[^A-Za-z0-9._:-]/g, "_")}`, kind: "gap", gap })),
|
|
278
|
+
];
|
|
279
|
+
const remaining = queue.filter(one => !skipped.has(one.key));
|
|
280
|
+
const item = remaining[0] ?? null;
|
|
281
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
282
|
+
if (item !== null && item.kind === "approval") {
|
|
283
|
+
// The card restates the digest-bound terms, so the nonce may be
|
|
284
|
+
// minted here — same rule as the task screen, same binding.
|
|
285
|
+
const ref = store.lookupRef(item.approval.taskId);
|
|
286
|
+
const scope = store.getScope(item.approval.taskId);
|
|
287
|
+
const planView = ref !== null && ref.plan === "drafted" ? planViewOf(ref.id) : null;
|
|
288
|
+
const approvalDigest = approvalFormDigest(item.approval.digest, planView?.sha256 ?? null);
|
|
289
|
+
// The same agents block the task page and chat sign under (v48),
|
|
290
|
+
// and the same consent door: closed, it mints no nonce.
|
|
291
|
+
const route = routeViewOf(item.approval.taskId, ref, scope, now, who);
|
|
292
|
+
const nonce = who.via === "cookie" && consentDoorOf(scope, route).open ? mintApprovalNonce(who.name, item.approval.taskId, approvalDigest) : "";
|
|
287
293
|
return sendScreen(response, 200, nextPage(chromeFor(project, "inbox"), {
|
|
288
|
-
item, scope
|
|
289
|
-
|
|
290
|
-
|
|
294
|
+
item, scope,
|
|
295
|
+
planDocument: planView?.document ?? null,
|
|
296
|
+
planContract: ref === null || planView === null ? null : planContractViewOf(ref.id, scope),
|
|
297
|
+
approvalDigest,
|
|
298
|
+
deliverable: ref?.deliverable ?? "branch",
|
|
299
|
+
route,
|
|
300
|
+
csrf, nonce, remaining: remaining.length, skipped: [...skipped], now,
|
|
291
301
|
}));
|
|
292
302
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// ONE place a done row becomes either lane; the done-lane render
|
|
346
|
-
// below never sees the rows filtered out here.
|
|
347
|
-
const unverifiedDone = done.filter(row => (row.proofVerdict === "short" || row.proofVerdict === "refuted") && !row.proofAccepted);
|
|
348
|
-
const verifiedDone = done.filter(row => !unverifiedDone.includes(row));
|
|
349
|
-
const unverifiedCards = unverifiedDone.map(row => {
|
|
350
|
-
// v40: the SAME chip gains one more word when a repair chain
|
|
351
|
-
// exists for this row's own run — never a second card.
|
|
352
|
-
const chain = row.runId === null ? null : store.repairChainFor(row.runId);
|
|
353
|
-
const repairChain = chain === null
|
|
354
|
-
? null
|
|
355
|
-
: {
|
|
356
|
-
attempt: chain.attempt,
|
|
357
|
-
outcome: chain.outcome,
|
|
358
|
-
approved: chain.draftTask !== null && (store.getScope(chain.draftTask)?.approvedAt ?? null) !== null,
|
|
359
|
-
};
|
|
360
|
-
return attentionCardForUnverifiedDone({
|
|
361
|
-
taskId: row.taskId,
|
|
362
|
-
title: row.title,
|
|
363
|
-
repo: row.repo,
|
|
364
|
-
completedAt: row.completedAt,
|
|
365
|
-
proofVerdict: row.proofVerdict,
|
|
366
|
-
proofMatrix: row.proofMatrix,
|
|
367
|
-
repairChain,
|
|
368
|
-
});
|
|
369
|
-
});
|
|
370
|
-
const laneCards = [...cards, ...unverifiedCards];
|
|
371
|
-
const body = boardBody({ cards: laneCards, done: verifiedDone, saturated: snapshot.saturated, now, all, project, delta }, pr => store.ciFailureObserved(pr));
|
|
372
|
-
if (url.searchParams.get("fragment") === "1") {
|
|
373
|
-
// The live region alone — the in-page swapper's diet. Same auth,
|
|
374
|
-
// same ceiling, no shell, no scripts (finding 2).
|
|
375
|
-
return respond(response, 200, "text/html; charset=utf-8", body);
|
|
376
|
-
}
|
|
377
|
-
if (url.searchParams.get("view") === "order") {
|
|
378
|
-
// The board's ORDER view (operator request): the same screen, flipped
|
|
379
|
-
// to dispatch order with the queue's drag handles. Reordering and
|
|
380
|
-
// reserving are scheduling, not authority, so this is the one view
|
|
381
|
-
// where a drag does anything; the state view stays a view.
|
|
382
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
383
|
-
const revision = who.via === "cookie" ? who.session.projectRevision : 0;
|
|
384
|
-
const region = queueRegionFor(project, csrf, revision, now);
|
|
385
|
-
return sendScreen(response, 200, screen("board", [
|
|
386
|
-
`<h1>Board</h1>`,
|
|
387
|
-
`<p class="meta board-view"><a href="/board">state</a> \u00b7 <strong>order</strong> <span class="meta">\u2014 drag to reorder, or onto a worker to reserve; the state view is where cards move on their own</span></p>`,
|
|
388
|
-
`<div id="queue-region">${region}</div>`,
|
|
389
|
-
`<p class="meta" id="queue-region-stamp"></p>`,
|
|
390
|
-
].join("\n"), { chrome: chromeFor(project, "board"), functional: { script: queueScript(), fetches: true } }));
|
|
303
|
+
return sendScreen(response, 200, nextPage(chromeFor(project, "inbox"), {
|
|
304
|
+
item, scope: null, planDocument: null, planContract: null, csrf, nonce: "",
|
|
305
|
+
approvalDigest: null, route: null,
|
|
306
|
+
remaining: remaining.length, skipped: [...skipped], now,
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
async function showBoard(ctx) {
|
|
310
|
+
const { url, who, response, now, project } = ctx;
|
|
311
|
+
// scope=all is the rolled-up view: every project this server was
|
|
312
|
+
// allowed to serve, on one board. The ceiling still rules row by row
|
|
313
|
+
// (rowVisible, the same predicate as every list) — a repo outside
|
|
314
|
+
// the server's configuration never renders a card, whatever the
|
|
315
|
+
// database holds. Unplaced work (repo NULL) appears: it dispatches
|
|
316
|
+
// anywhere, so every board honestly owns it.
|
|
317
|
+
const all = url.searchParams.get("scope") === "all";
|
|
318
|
+
// Roll-up admission happens BEFORE the query limit (Codex round 2,
|
|
319
|
+
// finding 11) — and root ceilings enumerate too, through the STORED
|
|
320
|
+
// repos that pass the ceiling (attended review, finding 3); the
|
|
321
|
+
// per-row visible() re-check below stays either way.
|
|
322
|
+
const admission = all ? admissionList() : null;
|
|
323
|
+
const snapshot = store.boardScoped(all ? null : project, now, 200, admission);
|
|
324
|
+
const admitted = all
|
|
325
|
+
? snapshot.tasks.filter(facts => facts.repo === null || visible(facts.repo))
|
|
326
|
+
: snapshot.tasks;
|
|
327
|
+
const done = all
|
|
328
|
+
? snapshot.done.filter(row => row.repo === null || visible(row.repo))
|
|
329
|
+
: snapshot.done;
|
|
330
|
+
// A blocker may live in a repo this server must not speak about —
|
|
331
|
+
// redact its state before the pure classifier composes a sentence
|
|
332
|
+
// from it (Codex round 2, finding 12). The dependency's NAME stays:
|
|
333
|
+
// the edge belongs to the visible task; the other project's live
|
|
334
|
+
// status does not.
|
|
335
|
+
const cards = admitted.map(facts => classify(facts.blockerRepo !== null && !visible(facts.blockerRepo)
|
|
336
|
+
? { ...facts, blockerState: null }
|
|
337
|
+
: facts, now));
|
|
338
|
+
// "Since you last looked": what concluded between this session's
|
|
339
|
+
// previous full board read and now. Fragment polls never move the
|
|
340
|
+
// anchor — an open tab is not a person looking.
|
|
341
|
+
let delta = null;
|
|
342
|
+
if (who.via === "cookie" && url.searchParams.get("fragment") !== "1") {
|
|
343
|
+
const prev = who.session.sawBoardAt;
|
|
344
|
+
if (prev !== null && now.getTime() - prev > 5 * 60_000) {
|
|
345
|
+
const sinceIso = new Date(prev).toISOString();
|
|
346
|
+
const runs = store.runsSinceScoped(sinceIso, all ? null : project).filter(one => all ? one.taskId !== "" : true);
|
|
347
|
+
delta = {
|
|
348
|
+
agoMinutes: Math.round((now.getTime() - prev) / 60_000),
|
|
349
|
+
built: runs.filter(one => one.outcome === "built" || one.outcome === "no-change").length,
|
|
350
|
+
failed: runs.filter(one => one.outcome === "failed").length,
|
|
351
|
+
questions: store.listDecisionsScoped(all ? null : project).filter(one => one.createdAt >= sinceIso && one.state !== "answered").length,
|
|
352
|
+
};
|
|
353
|
+
if (delta.built === 0 && delta.failed === 0 && delta.questions === 0)
|
|
354
|
+
delta = null;
|
|
391
355
|
}
|
|
392
|
-
|
|
393
|
-
return sendScreen(response, 200, screen("board", regionBody, {
|
|
394
|
-
chrome: chromeFor(project, "board", undefined, all ? "board-all" : undefined),
|
|
395
|
-
functional: { script: regionScript("board-region", "1", buildingCount > 0 ? 10 : 30), fetches: true },
|
|
396
|
-
}));
|
|
356
|
+
who.session.sawBoardAt = now.getTime();
|
|
397
357
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// The ranked queue itself stays bounded and says so.
|
|
425
|
-
const beyond = wantedId === null || inQueue !== null ? null : completedRowFor(wantedId, project);
|
|
426
|
-
const beyondRow = beyond === null ? null : resultRow(beyond);
|
|
427
|
-
const completedChoice = inQueue ?? (beyondRow === null ? null : { ...beyondRow, priority: reviewPriorityOf(beyondRow) });
|
|
428
|
-
const expectedRun = url.searchParams.get("run");
|
|
429
|
-
const namedRun = expectedRun !== null && /^[1-9]\d*$/.test(expectedRun) ? Number(expectedRun) : null;
|
|
430
|
-
// A build that failed (or stopped) has its own result page too: the run named, or a failed task's latest attempt.
|
|
431
|
-
const attempt = wantedId === null || (completedChoice !== null && (namedRun === null || completedChoice.runId === namedRun)) ? null : attemptRowFor(wantedId, namedRun, project);
|
|
432
|
-
const attemptRow = attempt === null ? null : resultRow(attempt);
|
|
433
|
-
const chosen = attemptRow !== null ? { ...attemptRow, priority: reviewPriorityOf(attemptRow) } : completedChoice;
|
|
434
|
-
const selectedRow = wantedId === null ? ranked[0] ?? null : chosen;
|
|
435
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
436
|
-
// An exact link to a result this person can't see (or that never existed) reads the same either way.
|
|
437
|
-
if (expectedRun !== null && wantedId !== null && chosen === null)
|
|
438
|
-
return refuse(response, who, 404, "No such result in your projects.", "/work");
|
|
439
|
-
if (expectedRun !== null && (!/^[1-9]\d*$/.test(expectedRun) || selectedRow?.runId !== Number(expectedRun))) {
|
|
440
|
-
return sendScreen(response, 409, screen("Result changed", '<h1>Result changed</h1><p>This acceptance link no longer matches the current result. Review the current task before accepting.</p><p class="refusal-back"><a class="button-link" href="/review">Review results</a></p>', { chrome: chromeFor(project, "runs") }));
|
|
441
|
-
}
|
|
442
|
-
const selected = selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
|
|
443
|
-
// A build that delivered nothing reads Failed here, and so does a failed task's delivered result: what went wrong
|
|
444
|
-
// is the card and Retry the ink act. That result may still be accepted, but only in outline, as Accept anyway, with
|
|
445
|
-
// a reason.
|
|
446
|
-
const delivered = selected?.run?.outcome === "built" || selected?.run?.outcome === "no-change";
|
|
447
|
-
const failedTask = selected === null ? null : store.getTask(selected.taskId);
|
|
448
|
-
if (selected !== null && attemptRow !== null && selected.run !== null && (!delivered || failedTask?.state === "failed")) {
|
|
449
|
-
const attemptRun = store.getRun(selected.run.id);
|
|
450
|
-
const runs = attemptRun === null ? [] : store.runsFor(attemptRun.taskRef);
|
|
451
|
-
const latest = latestFinishedAttempt(runs);
|
|
452
|
-
// Retry from here only when the task failed, nothing holds it, and this is its latest attempt or a result it delivered.
|
|
453
|
-
const retry = failedTask?.state === "failed" && (latest?.id === selected.run.id || delivered) && csrf !== "" && who.role === "approver" && store.currentLiveLease(attemptRun.taskRef, now) === null
|
|
454
|
-
? { action: `${taskHref(selected.taskId)}/requeue` } : null;
|
|
455
|
-
// Accept anyway: the same acceptance this task's latest result takes, never for an older one or one already accepted.
|
|
456
|
-
const family = delivered ? familyOf(selected.taskId) : null;
|
|
457
|
-
const acceptAnyway = delivered && csrf !== "" && who.role === "approver" && family?.current.id === selected.taskId && family.problem === null &&
|
|
458
|
-
runs.find(runIsTaskResult)?.id === selected.run.id && store.proofAcceptance(selected.run.id) === null
|
|
459
|
-
? { action: `${taskHref(selected.taskId)}/accept-proof`, run: selected.run.id } : null;
|
|
460
|
-
if (attemptRun !== null) {
|
|
461
|
-
// A delivered result didn't fail itself: the task's failure is its latest attempt's.
|
|
462
|
-
const failure = delivered ? failureOf(runs, null) : explainAttempt(attemptRun, null);
|
|
463
|
-
selected.failure = { ...failure, retry: retry === null ? null : { ...retry, note: retryNoteOf(failure.suggestion) }, ...(acceptAnyway === null ? {} : { acceptAnyway }) };
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
const reviewPage = reviewCockpitPage(chromeFor(wantedId === null ? project : chosenProject, "runs"), {
|
|
467
|
-
queue: ranked,
|
|
468
|
-
queueCap: REVIEW_QUEUE_CAP,
|
|
469
|
-
selected,
|
|
470
|
-
beyondQueue: beyond !== null && attemptRow === null,
|
|
471
|
-
// A deep link to a result this console cannot show — not done,
|
|
472
|
-
// not admitted, or never existed — says so in one sentence and
|
|
473
|
-
// shows the top of the queue; the three cases read identically.
|
|
474
|
-
missing: wantedId !== null && chosen === null ? wantedId : null,
|
|
475
|
-
csrf,
|
|
476
|
-
canRetryReview: who.via === "cookie" && who.role === "approver",
|
|
477
|
-
noted: url.searchParams.get("noted") !== null,
|
|
478
|
-
refusal: Object.hasOwn(RESULT_REFUSALS, url.searchParams.get("refused") ?? "") ? RESULT_REFUSALS[url.searchParams.get("refused")] : null,
|
|
479
|
-
tab: parseResultTab(url.searchParams.get("tab")),
|
|
480
|
-
user: who.name,
|
|
481
|
-
now,
|
|
358
|
+
// A completed task whose proof is short or refuted and not yet
|
|
359
|
+
// accepted (Priority 2) reads "needs verification", not done — the
|
|
360
|
+
// board's once-and-only-once rule holds because this split is the
|
|
361
|
+
// ONE place a done row becomes either lane; the done-lane render
|
|
362
|
+
// below never sees the rows filtered out here.
|
|
363
|
+
const unverifiedDone = done.filter(row => (row.proofVerdict === "short" || row.proofVerdict === "refuted") && !row.proofAccepted);
|
|
364
|
+
const verifiedDone = done.filter(row => !unverifiedDone.includes(row));
|
|
365
|
+
const unverifiedCards = unverifiedDone.map(row => {
|
|
366
|
+
// v40: the SAME chip gains one more word when a repair chain
|
|
367
|
+
// exists for this row's own run — never a second card.
|
|
368
|
+
const chain = row.runId === null ? null : store.repairChainFor(row.runId);
|
|
369
|
+
const repairChain = chain === null
|
|
370
|
+
? null
|
|
371
|
+
: {
|
|
372
|
+
attempt: chain.attempt,
|
|
373
|
+
outcome: chain.outcome,
|
|
374
|
+
approved: chain.draftTask !== null && (store.getScope(chain.draftTask)?.approvedAt ?? null) !== null,
|
|
375
|
+
};
|
|
376
|
+
return attentionCardForUnverifiedDone({
|
|
377
|
+
taskId: row.taskId,
|
|
378
|
+
title: row.title,
|
|
379
|
+
repo: row.repo,
|
|
380
|
+
completedAt: row.completedAt,
|
|
381
|
+
proofVerdict: row.proofVerdict,
|
|
382
|
+
proofMatrix: row.proofMatrix,
|
|
383
|
+
repairChain,
|
|
482
384
|
});
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
return sendScreen(response, 200, reviewPage);
|
|
491
|
-
}
|
|
492
|
-
if (url.pathname === "/done") {
|
|
493
|
-
return sendScreen(response, 200, donePage(chromeFor(project, "runs"), store.listCompletedWorkScoped(project, 50), pr => store.ciFailureObserved(pr)));
|
|
494
|
-
}
|
|
495
|
-
if (url.pathname === "/tasks") {
|
|
496
|
-
const wanted = url.searchParams.get("state");
|
|
497
|
-
if (wanted !== null && !TASK_STATES.includes(wanted)) {
|
|
498
|
-
return refuse(response, who, 400, "no such state", "/tasks");
|
|
499
|
-
}
|
|
500
|
-
// ?template=<name> pre-fills the add form from the shipped library —
|
|
501
|
-
// a pre-filled form and nothing more; the submission path is the
|
|
502
|
-
// same guarded handler either way.
|
|
503
|
-
const fromTemplate = url.searchParams.get("template");
|
|
504
|
-
const picked = fromTemplate === null ? null : templateByName(fromTemplate);
|
|
505
|
-
const prefill = picked !== null && picked.kind === "task"
|
|
506
|
-
? {
|
|
507
|
-
title: picked.title, goal: picked.goal, not: picked.outOfScope ?? "", touches: picked.touches.join(", "),
|
|
508
|
-
acceptance: acceptanceToLines(picked.acceptance).join("\n"),
|
|
509
|
-
}
|
|
510
|
-
: null;
|
|
511
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
512
|
-
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()));
|
|
385
|
+
});
|
|
386
|
+
const laneCards = [...cards, ...unverifiedCards];
|
|
387
|
+
const body = boardBody({ cards: laneCards, done: verifiedDone, saturated: snapshot.saturated, now, all, project, delta }, pr => store.ciFailureObserved(pr));
|
|
388
|
+
if (url.searchParams.get("fragment") === "1") {
|
|
389
|
+
// The live region alone — the in-page swapper's diet. Same auth,
|
|
390
|
+
// same ceiling, no shell, no scripts (finding 2).
|
|
391
|
+
return respond(response, 200, "text/html; charset=utf-8", body);
|
|
513
392
|
}
|
|
514
|
-
if (url.
|
|
393
|
+
if (url.searchParams.get("view") === "order") {
|
|
394
|
+
// The board's ORDER view (operator request): the same screen, flipped
|
|
395
|
+
// to dispatch order with the queue's drag handles. Reordering and
|
|
396
|
+
// reserving are scheduling, not authority, so this is the one view
|
|
397
|
+
// where a drag does anything; the state view stays a view.
|
|
515
398
|
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
516
399
|
const revision = who.via === "cookie" ? who.session.projectRevision : 0;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
400
|
+
const region = queueRegionFor(project, csrf, revision, now);
|
|
401
|
+
return sendScreen(response, 200, screen("board", joinHtml([
|
|
402
|
+
html `<h1>Board</h1>`,
|
|
403
|
+
html `<p class="meta board-view"><a href="/board">state</a> \u00b7 <strong>order</strong> <span class="meta">\u2014 drag to reorder, or onto a worker to reserve; the state view is where cards move on their own</span></p>`,
|
|
404
|
+
html `<div id="queue-region">${region}</div>`,
|
|
405
|
+
html `<p class="meta" id="queue-region-stamp"></p>`,
|
|
406
|
+
], "\n"), { chrome: chromeFor(project, "board"), functional: { script: queueScript(), fetches: true } }));
|
|
407
|
+
}
|
|
408
|
+
const regionBody = html `<div id="board-region">${body}</div><p class="meta" id="board-region-stamp"></p>`;
|
|
409
|
+
return sendScreen(response, 200, screen("board", regionBody, {
|
|
410
|
+
chrome: chromeFor(project, "board", undefined, all ? "board-all" : undefined),
|
|
411
|
+
functional: { script: regionScript("board-region", "1"), fetches: true },
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
async function showReview(ctx) {
|
|
415
|
+
const { url, who, response, now, project, chosenProject } = ctx;
|
|
416
|
+
// The review cockpit (Priority 5): every visible COMPLETED task,
|
|
417
|
+
// ranked by review priority, with one selected result projected
|
|
418
|
+
// from the records the task, run, and done pages already read —
|
|
419
|
+
// scope, plan, run, artifacts, verdict, publication. The
|
|
420
|
+
// ranking is a labeled presentation aid: it never rewrites the
|
|
421
|
+
// stored verdict, and the sealed patch downloads exactly as stored.
|
|
422
|
+
// Admission binds BEFORE the SQL limit (the done page's own rule),
|
|
423
|
+
// and every row is re-proved against the ceiling before ranking.
|
|
424
|
+
const resultRow = (row) => {
|
|
425
|
+
const assignment = assignmentOf(store, row.taskId, now, workAccess(), evidenceRoot);
|
|
426
|
+
return { ...row, proofReasons: row.runId === null ? [] : store.proofVerdictFor(row.runId)?.reasons ?? [], ciFailing: ciFailingFor(row.runId, row.prNumber),
|
|
427
|
+
assignment: assignment?.activeTaskId === row.taskId && (assignment.receipt?.runId ?? null) === row.runId ? assignment : null };
|
|
428
|
+
};
|
|
429
|
+
const rows = familiesInView(project, { states: ["done"], limit: REVIEW_QUEUE_CAP }).flatMap(family => {
|
|
430
|
+
const row = completedRowFor(family.current.id, project);
|
|
431
|
+
return row === null ? [] : [{ ...resultRow(row), title: family.root.title }];
|
|
432
|
+
});
|
|
433
|
+
const ranked = rankReviewQueue(rows);
|
|
434
|
+
const wanted = url.searchParams.get("result");
|
|
435
|
+
const wantedId = wanted === null || wanted.length === 0 || wanted.length > 64 || hasForbiddenControls(wanted) ? null : wanted;
|
|
436
|
+
const inQueue = wantedId === null ? null : ranked.find(one => one.taskId === wantedId) ?? null;
|
|
437
|
+
// A stable deep link outlives the queue window (v2 review, comment
|
|
438
|
+
// 1): a completion older than the newest REVIEW_QUEUE_CAP resolves
|
|
439
|
+
// directly — re-proved as done, in this project, and inside the
|
|
440
|
+
// ceiling — so an old receipt never reads as unfinished or foreign.
|
|
441
|
+
// The ranked queue itself stays bounded and says so.
|
|
442
|
+
const beyond = wantedId === null || inQueue !== null ? null : completedRowFor(wantedId, project);
|
|
443
|
+
const beyondRow = beyond === null ? null : resultRow(beyond);
|
|
444
|
+
const completedChoice = inQueue ?? (beyondRow === null ? null : { ...beyondRow, priority: reviewPriorityOf(beyondRow) });
|
|
445
|
+
const expectedRun = url.searchParams.get("run");
|
|
446
|
+
const namedRun = expectedRun !== null && /^[1-9]\d*$/.test(expectedRun) ? Number(expectedRun) : null;
|
|
447
|
+
// A build that failed (or stopped) has its own result page too: the run named, or a failed task's latest attempt.
|
|
448
|
+
const attempt = wantedId === null || (completedChoice !== null && (namedRun === null || completedChoice.runId === namedRun)) ? null : attemptRowFor(wantedId, namedRun, project);
|
|
449
|
+
const attemptRow = attempt === null ? null : resultRow(attempt);
|
|
450
|
+
const chosen = attemptRow !== null ? { ...attemptRow, priority: reviewPriorityOf(attemptRow) } : completedChoice;
|
|
451
|
+
const selectedRow = wantedId === null ? ranked[0] ?? null : chosen;
|
|
452
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
453
|
+
// An exact link to a result this person can't see (or that never existed) reads the same either way.
|
|
454
|
+
if (expectedRun !== null && wantedId !== null && chosen === null)
|
|
455
|
+
return refuse(response, who, 404, "No such result in your projects.", "/work");
|
|
456
|
+
if (expectedRun !== null && (!/^[1-9]\d*$/.test(expectedRun) || selectedRow?.runId !== Number(expectedRun))) {
|
|
457
|
+
return sendScreen(response, 409, screen("Result changed", html `<h1>Result changed</h1><p>This acceptance link no longer matches the current result. Review the current task before accepting.</p><p class="refusal-back"><a class="button-link" href="/review">Review results</a></p>`, { chrome: chromeFor(project, "runs") }));
|
|
458
|
+
}
|
|
459
|
+
const selected = selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
|
|
460
|
+
// A build that delivered nothing reads Failed here, and so does a failed task's delivered result: what went wrong
|
|
461
|
+
// is the card and Retry the ink act. That result may still be accepted, but only in outline, as Accept anyway, with
|
|
462
|
+
// a reason.
|
|
463
|
+
const delivered = selected?.run?.outcome === "built" || selected?.run?.outcome === "no-change";
|
|
464
|
+
const failedTask = selected === null ? null : store.getTask(selected.taskId);
|
|
465
|
+
if (selected !== null && attemptRow !== null && selected.run !== null && (!delivered || failedTask?.state === "failed")) {
|
|
466
|
+
const attemptRun = store.getRun(selected.run.id);
|
|
467
|
+
const runs = attemptRun === null ? [] : store.runsFor(attemptRun.taskRef);
|
|
468
|
+
const latest = latestFinishedAttempt(runs);
|
|
469
|
+
// Retry from here only when the task failed, nothing holds it, and this is its latest attempt or a result it delivered.
|
|
470
|
+
const retry = failedTask?.state === "failed" && (latest?.id === selected.run.id || delivered) && csrf !== "" && who.role === "approver" && store.currentLiveLease(attemptRun.taskRef, now) === null
|
|
471
|
+
? { action: `${taskHref(selected.taskId)}/requeue` } : null;
|
|
472
|
+
// Accept anyway: the same acceptance this task's latest result takes, never for an older one or one already accepted.
|
|
473
|
+
const family = delivered ? familyOf(selected.taskId) : null;
|
|
474
|
+
const acceptAnyway = delivered && csrf !== "" && who.role === "approver" && family?.current.id === selected.taskId && family.problem === null &&
|
|
475
|
+
runs.find(runIsTaskResult)?.id === selected.run.id && store.proofAcceptance(selected.run.id) === null
|
|
476
|
+
? { action: `${taskHref(selected.taskId)}/accept-proof`, run: selected.run.id } : null;
|
|
477
|
+
if (attemptRun !== null) {
|
|
478
|
+
// A delivered result didn't fail itself: the task's failure is its latest attempt's.
|
|
479
|
+
const failure = delivered ? failureOf(runs, null) : explainAttempt(attemptRun, null);
|
|
480
|
+
selected.failure = { ...failure, retry: retry === null ? null : { ...retry, note: retryNoteOf(failure.suggestion) }, ...(acceptAnyway === null ? {} : { acceptAnyway }) };
|
|
522
481
|
}
|
|
523
|
-
// The queue is the board's order view (reduction pass §1): the URL
|
|
524
|
-
// keeps answering, as a redirect, so nothing anyone bookmarked 404s.
|
|
525
|
-
return redirect(response, QUEUE_VIEW);
|
|
526
482
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
483
|
+
const reviewPage = reviewCockpitPage(chromeFor(wantedId === null ? project : chosenProject, "runs"), {
|
|
484
|
+
queue: ranked,
|
|
485
|
+
queueCap: REVIEW_QUEUE_CAP,
|
|
486
|
+
selected,
|
|
487
|
+
beyondQueue: beyond !== null && attemptRow === null,
|
|
488
|
+
// A deep link to a result this console cannot show — not done,
|
|
489
|
+
// not admitted, or never existed — says so in one sentence and
|
|
490
|
+
// shows the top of the queue; the three cases read identically.
|
|
491
|
+
missing: wantedId !== null && chosen === null ? wantedId : null,
|
|
492
|
+
csrf,
|
|
493
|
+
canRetryReview: who.via === "cookie" && who.role === "approver",
|
|
494
|
+
noted: url.searchParams.get("noted") !== null,
|
|
495
|
+
refusal: Object.hasOwn(RESULT_REFUSALS, url.searchParams.get("refused") ?? "") ? RESULT_REFUSALS[url.searchParams.get("refused")] : null,
|
|
496
|
+
tab: parseResultTab(url.searchParams.get("tab")),
|
|
497
|
+
user: who.name,
|
|
498
|
+
now,
|
|
499
|
+
});
|
|
500
|
+
// The result's task conversation, docked beside it (v77); messages
|
|
501
|
+
// sent here carry the result being viewed.
|
|
502
|
+
const reviewFocus = selected === null ? null : taskChatFocus(selected.taskId, now, who, { mintNonce: false });
|
|
503
|
+
const reviewDocked = reviewFocus === null || selected === null ? null
|
|
504
|
+
: dockedConversation(who, reviewFocus, null, now, reviewHref(selected.taskId), selected.run !== null && reviewFocus.family.versions.some(one => one.refId === store.getRun(selected.run.id)?.taskRef) ? selected.run.id : null);
|
|
505
|
+
if (reviewDocked !== null)
|
|
506
|
+
reviewPage.workspace = { ...reviewPage.workspace, conversation: reviewDocked, pageHtml: reviewPage.body };
|
|
507
|
+
return sendScreen(response, 200, reviewPage);
|
|
508
|
+
}
|
|
509
|
+
async function showDone(ctx) {
|
|
510
|
+
const { response, project } = ctx;
|
|
511
|
+
return sendScreen(response, 200, donePage(chromeFor(project, "runs"), store.listCompletedWorkScoped(project, 50), pr => store.ciFailureObserved(pr)));
|
|
512
|
+
}
|
|
513
|
+
async function showTasks(ctx) {
|
|
514
|
+
const { url, who, response, project } = ctx;
|
|
515
|
+
const wanted = url.searchParams.get("state");
|
|
516
|
+
if (wanted !== null && !TASK_STATES.includes(wanted)) {
|
|
517
|
+
return refuse(response, who, 400, "no such state", "/tasks");
|
|
518
|
+
}
|
|
519
|
+
// ?template=<name> pre-fills the add form from the shipped library —
|
|
520
|
+
// a pre-filled form and nothing more; the submission path is the
|
|
521
|
+
// same guarded handler either way.
|
|
522
|
+
const fromTemplate = url.searchParams.get("template");
|
|
523
|
+
const picked = fromTemplate === null ? null : templateByName(fromTemplate);
|
|
524
|
+
const prefill = picked !== null && picked.kind === "task"
|
|
525
|
+
? {
|
|
526
|
+
title: picked.title, goal: picked.goal, not: picked.outOfScope ?? "", touches: picked.touches.join(", "),
|
|
527
|
+
acceptance: acceptanceToLines(picked.acceptance).join("\n"),
|
|
528
|
+
}
|
|
529
|
+
: null;
|
|
530
|
+
return sendScreen(response, 200, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project, wanted === null ? undefined : wanted).slice(0, 200), wanted, null, project, prefill, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
531
|
+
}
|
|
532
|
+
async function showQueue(ctx) {
|
|
533
|
+
const { url, who, response, project } = ctx;
|
|
534
|
+
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
535
|
+
const revision = who.via === "cookie" ? who.session.projectRevision : 0;
|
|
536
|
+
// Column headers read one thing beyond the queue snapshot: live claims
|
|
537
|
+
// in THIS project, per worker (the capacity is global), so the header
|
|
538
|
+
// names both and never a ratio.
|
|
539
|
+
if (url.searchParams.get("fragment") === "1") {
|
|
540
|
+
return respond(response, 200, "text/html; charset=utf-8", queueRegionFor(project, csrf, revision, clock()));
|
|
556
541
|
}
|
|
542
|
+
// The queue is the board's order view (reduction pass §1): the URL
|
|
543
|
+
// keeps answering, as a redirect, so nothing anyone bookmarked 404s.
|
|
544
|
+
return redirect(response, QUEUE_VIEW);
|
|
545
|
+
}
|
|
546
|
+
async function showNewTask(ctx) {
|
|
547
|
+
const { who, response, project } = ctx;
|
|
548
|
+
const revision = who.via === "cookie" ? who.session.projectRevision : 0;
|
|
549
|
+
const chainable = store
|
|
550
|
+
.listTasksScoped(project, undefined, 100, null)
|
|
551
|
+
.filter(one => one.state !== "done" && one.state !== "cancelled" && visible(one.repo))
|
|
552
|
+
.map(one => ({ id: one.id, title: one.title }));
|
|
553
|
+
const chrome = chromeFor(project, "tasks");
|
|
554
|
+
return sendScreen(response, 200, newTaskPage(chrome, project, revision, null, chainable, store.permissionDefault().mode, store.qualityDefault().mode, chrome.projects ?? []));
|
|
555
|
+
}
|
|
556
|
+
async function showTask(ctx) {
|
|
557
|
+
const { url, who, response } = ctx;
|
|
557
558
|
const task = matchTaskPath(url.pathname, "");
|
|
558
|
-
if (task
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return
|
|
569
|
-
|
|
570
|
-
|
|
559
|
+
if (task === null)
|
|
560
|
+
return noPage(who, response);
|
|
561
|
+
const family = familyOf(task.taskId);
|
|
562
|
+
if (family === null)
|
|
563
|
+
return refuse(response, who, 404, "no such task", "/tasks");
|
|
564
|
+
const version = url.searchParams.get("version");
|
|
565
|
+
if (version !== null && !family.versions.some(one => one.id === version))
|
|
566
|
+
return refuse(response, who, 404, "That version is not available for this task.", taskHref(family.root.id));
|
|
567
|
+
// A version's own address lands on its family's page, still asking for the plan editor when it did (Chat's Edit plan).
|
|
568
|
+
if (task.taskId !== family.root.id)
|
|
569
|
+
return redirect(response, `${taskHref(family.root.id)}?version=${encodeURIComponent(task.taskId)}${url.searchParams.get("edit") === "plan" ? "&edit=plan" : ""}`);
|
|
570
|
+
return taskScreen(response, who, version ?? family.current.id, null, 200, undefined, undefined, url.searchParams.get("edit") === "plan");
|
|
571
|
+
}
|
|
572
|
+
// v103: a task's evidence pack, as a printable page or JSON (the whole family, sealed ledger entries included).
|
|
573
|
+
async function showTaskEvidence(ctx) {
|
|
574
|
+
const { url, who, response, now } = ctx;
|
|
571
575
|
const evidence = matchTaskPath(url.pathname, "/evidence$");
|
|
572
|
-
if (evidence
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
return sendScreen(response, 200, screen(`Evidence pack · ${family.root.id}`, evidencePackHtml(pack), { chrome: chromeFor(family.root.repo, "tasks") }));
|
|
576
|
+
if (evidence === null)
|
|
577
|
+
return noPage(who, response);
|
|
578
|
+
const access = workAccess();
|
|
579
|
+
const family = store.taskFamilyOf(evidence.taskId, access.repos, access.includeUnplaced);
|
|
580
|
+
if (family === null || !visible(family.root.repo))
|
|
581
|
+
return refuse(response, who, 404, "no such task", "/tasks");
|
|
582
|
+
if (evidence.taskId !== family.root.id)
|
|
583
|
+
return redirect(response, `${taskHref(family.root.id)}/evidence${url.search}`);
|
|
584
|
+
const pack = evidencePack(store, family.root.id, access, who.name, now, evidenceRoot);
|
|
585
|
+
if (pack === null)
|
|
586
|
+
return refuse(response, who, 404, "no such task", "/tasks");
|
|
587
|
+
if (url.searchParams.get("format") === "json") {
|
|
588
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": `attachment; filename="evidence-${family.root.id.replace(/[^A-Za-z0-9._-]/g, "_")}.json"`,
|
|
589
|
+
"cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
590
|
+
return void response.end(JSON.stringify(checkResponse("evidencePack", pack), null, 2));
|
|
588
591
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const accepted = new Set(rows.filter(one => store.proofAcceptance(one.id) !== null).map(one => one.id));
|
|
601
|
-
return sendScreen(response, 200, runsPage(chromeFor(project, "runs"), rows, liveRunIds(rows), rows.length === RUNS_PAGE ? rows[rows.length - 1]?.id ?? null : null, verdicts, accepted));
|
|
592
|
+
return sendScreen(response, 200, screen(`Evidence pack · ${family.root.id}`, evidencePackHtml(pack), { chrome: chromeFor(family.root.repo, "tasks") }));
|
|
593
|
+
}
|
|
594
|
+
async function showRuns(ctx) {
|
|
595
|
+
const { url, who, response, project } = ctx;
|
|
596
|
+
const raw = url.searchParams.get("before");
|
|
597
|
+
let before = null;
|
|
598
|
+
if (raw !== null) {
|
|
599
|
+
if (!/^[1-9][0-9]{0,14}$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
|
|
600
|
+
return refuse(response, who, 400, "that cursor is not a page", "/runs");
|
|
601
|
+
}
|
|
602
|
+
before = Number(raw);
|
|
602
603
|
}
|
|
604
|
+
const rows = store.listRunsBefore(before, RUNS_PAGE, project);
|
|
605
|
+
const verdicts = store.proofVerdictsFor(rows.map(one => one.id));
|
|
606
|
+
const accepted = new Set(rows.filter(one => store.proofAcceptance(one.id) !== null).map(one => one.id));
|
|
607
|
+
return sendScreen(response, 200, runsPage(chromeFor(project, "runs"), rows, liveRunIds(rows), rows.length === RUNS_PAGE ? rows[rows.length - 1]?.id ?? null : null, verdicts, accepted));
|
|
608
|
+
}
|
|
609
|
+
async function showRun(ctx) {
|
|
610
|
+
const { url, who, response, now, project } = ctx;
|
|
603
611
|
const run = /^\/r\/([0-9]{1,15})$/.exec(url.pathname);
|
|
604
|
-
if (run
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const taskId = store.externalIdFor(found.taskRef) ?? "?";
|
|
610
|
-
const running = runIsLive(found);
|
|
611
|
-
if (url.searchParams.get("fragment") === "check") {
|
|
612
|
-
response.setHeader("cache-control", "no-store");
|
|
613
|
-
return respond(response, 200, "text/html; charset=utf-8", checkProgressHtml(store.checkProgress(found.id)));
|
|
614
|
-
}
|
|
615
|
-
if (url.searchParams.get("fragment") === "facts") {
|
|
616
|
-
// The facts region alone — same auth and ceiling as the page; a
|
|
617
|
-
// finished or no-longer-live run says so rather than growing forms
|
|
618
|
-
// (finding 5; round-4 finding 15).
|
|
619
|
-
return respond(response, 200, "text/html; charset=utf-8", runFactsFragment(found, taskId, running, store.runRoute(found.id)));
|
|
620
|
-
}
|
|
621
|
-
if (url.searchParams.get("fragment") === "peek") {
|
|
622
|
-
// The live peek: cookie sessions only (v2 §3), never stored, never
|
|
623
|
-
// cached by anything downstream.
|
|
624
|
-
if (who.via !== "cookie")
|
|
625
|
-
return respond(response, 403, "text/plain; charset=utf-8", "the live peek is a browser session's view");
|
|
626
|
-
const peeked = await peekFragment(found.id, who.session.csrf, options.editorLinks !== undefined && found.runner === options.localRunner && who.session.editorLinks === true);
|
|
627
|
-
response.setHeader("cache-control", "no-store");
|
|
628
|
-
if (peeked.retryAfter !== undefined)
|
|
629
|
-
response.setHeader("retry-after", String(peeked.retryAfter));
|
|
630
|
-
return respond(response, peeked.status, "text/html; charset=utf-8", peeked.body);
|
|
631
|
-
}
|
|
632
|
-
if (url.searchParams.get("fragment") === "transcript") {
|
|
633
|
-
// The live transcript window (arc 1): raw sanitized TEXT as JSON —
|
|
634
|
-
// the sink is textContent, never innerHTML. Guards, enumerated:
|
|
635
|
-
// cookie session; run visible (proved above); this machine's
|
|
636
|
-
// runner asserted; byte offsets validated; the file read through
|
|
637
|
-
// its own descriptor with the exact numeric-id name. Display
|
|
638
|
-
// state, not evidence — and never cached.
|
|
639
|
-
response.setHeader("cache-control", "no-store");
|
|
640
|
-
if (who.via !== "cookie")
|
|
641
|
-
return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
|
|
642
|
-
if (options.localRunner === undefined) {
|
|
643
|
-
return respond(response, 200, "application/json", JSON.stringify({ error: "off" }));
|
|
644
|
-
}
|
|
645
|
-
const fromRaw = url.searchParams.get("from") ?? "0";
|
|
646
|
-
const from = /^[0-9]{1,15}$/.test(fromRaw) ? Number(fromRaw) : Number.NaN;
|
|
647
|
-
if (!Number.isSafeInteger(from) || from < 0) {
|
|
648
|
-
return respond(response, 400, "application/json", JSON.stringify({ error: "offset" }));
|
|
649
|
-
}
|
|
650
|
-
const live = runIsLive(found);
|
|
651
|
-
// The final drain: a finished run's tail — including a torn last
|
|
652
|
-
// line — may be read until the sweep removes the file.
|
|
653
|
-
const window = readLiveWindow(evidenceRoot, found.id, from, !live);
|
|
654
|
-
if (!window.ok) {
|
|
655
|
-
if (window.reason === "replaced") {
|
|
656
|
-
return respond(response, 409, "application/json", JSON.stringify({ error: "replaced" }));
|
|
657
|
-
}
|
|
658
|
-
// Missing or unreadable: nothing to show. Final only when the
|
|
659
|
-
// run can never write again.
|
|
660
|
-
return respond(response, 200, "application/json", JSON.stringify({ text: "", nextOffset: from, final: !live }));
|
|
661
|
-
}
|
|
662
|
-
// Re-proof after the read (peek discipline): the run row still says
|
|
663
|
-
// what admission said, or nothing is shown.
|
|
664
|
-
const again = store.getRun(found.id);
|
|
665
|
-
if (again === null || !runVisible(again)) {
|
|
666
|
-
return respond(response, 200, "application/json", JSON.stringify({ text: "", nextOffset: from, final: true }));
|
|
667
|
-
}
|
|
668
|
-
return respond(response, 200, "application/json", JSON.stringify({ text: window.text, nextOffset: window.nextOffset, final: !live && window.eof }));
|
|
669
|
-
}
|
|
670
|
-
// One result page (2026-10-02): a builder's finished result opens on
|
|
671
|
-
// its task's result page, titled with the task. The raw run record
|
|
672
|
-
// waits there under Details, and here with ?record=1.
|
|
673
|
-
if (url.searchParams.get("record") === null && !running && found.role === "builder" && (found.outcome === "built" || found.outcome === "no-change") &&
|
|
674
|
-
completedRowFor(taskId, null)?.runId === found.id) {
|
|
675
|
-
const tab = parseResultTab(url.searchParams.get("tab"));
|
|
676
|
-
return redirect(response, `${reviewHref(taskId, found.id)}${tab === "summary" ? "" : `&tab=${tab}`}`);
|
|
677
|
-
}
|
|
678
|
-
// A build that failed or stopped opens on its own result page too: its changes, its checks and what went wrong.
|
|
679
|
-
if (url.searchParams.get("record") === null && !running && (found.role === "builder" || found.role === "scout") && attemptRowFor(taskId, found.id, null) !== null) {
|
|
680
|
-
const tab = parseResultTab(url.searchParams.get("tab"));
|
|
681
|
-
return redirect(response, `${reviewHref(taskId, found.id)}${tab === "summary" ? "" : `&tab=${tab}`}`);
|
|
682
|
-
}
|
|
683
|
-
// Pollers exist only for a LIVE run — an orphaned null-outcome run
|
|
684
|
-
// would otherwise be refetched forever (finding 15). The nonce is
|
|
685
|
-
// sendScreen's business now.
|
|
686
|
-
const artifacts = store.artifactsFor(found.id);
|
|
687
|
-
return sendScreen(response, 200, runPage(chromeFor(project, "runs", runListPane(project, found.id)), found, taskId, artifacts, terminalDiffView(artifacts, evidenceRoot), store.notesForRun(found.id), who.via === "cookie" ? who.session.csrf : "", store.liveDiffComments(found.id).filter(isRevisionFeedback), (() => {
|
|
688
|
-
const publication = store.publicationForRun(found.id);
|
|
689
|
-
return publication !== null && publication.prNumber !== null && store.hasOpenCiEpisode(publication.githubRepo, publication.prNumber)
|
|
690
|
-
? { pr: publication.prNumber }
|
|
691
|
-
: null;
|
|
692
|
-
})(), !running
|
|
693
|
-
? undefined
|
|
694
|
-
: regionScript("run-facts", "facts", 10) +
|
|
695
|
-
(options.localRunner === undefined ? "" : regionScript("run-peek", "peek", 15)) +
|
|
696
|
-
(options.localRunner === undefined ? "" : transcriptScript()), options.localRunner !== undefined, running,
|
|
697
|
-
// Editor links (arc 6): three statements align or nothing renders —
|
|
698
|
-
// the deployment capability, THIS machine's runner owning the run,
|
|
699
|
-
// and the session's own device-side yes.
|
|
700
|
-
options.editorLinks !== undefined &&
|
|
701
|
-
options.localRunner !== undefined &&
|
|
702
|
-
found.runner === options.localRunner &&
|
|
703
|
-
who.via === "cookie" &&
|
|
704
|
-
who.session.editorLinks === true &&
|
|
705
|
-
// A reviewer run (v29) never had a checkout — no files to open.
|
|
706
|
-
found.worktree !== null
|
|
707
|
-
? { worktree: found.worktree }
|
|
708
|
-
: null, options.editorLinks !== undefined &&
|
|
709
|
-
options.localRunner !== undefined &&
|
|
710
|
-
found.runner === options.localRunner &&
|
|
711
|
-
who.via === "cookie"
|
|
712
|
-
? { on: who.session.editorLinks === true }
|
|
713
|
-
: null, url.searchParams.get("noted") !== null, structuredHandoffView(artifacts, evidenceRoot), proofBundleView(store, found, artifacts, evidenceRoot), store.runRoute(found.id), store.publicationForRun(found.id), reviewFactsFor(found.id),
|
|
714
|
-
// The shared result presentation (package 3) for a finished result.
|
|
715
|
-
!running && (found.outcome === "built" || found.outcome === "no-change")
|
|
716
|
-
? { detail: resultDetailOf(found, who, now), tab: parseResultTab(url.searchParams.get("tab")), user: who.name, requestToken: randomBytes(16).toString("hex") }
|
|
717
|
-
: null, store.getScope(taskId)?.digest ?? null));
|
|
612
|
+
if (run === null)
|
|
613
|
+
return noPage(who, response);
|
|
614
|
+
const found = store.getRun(Number(run[1]));
|
|
615
|
+
if (found === null || !runVisible(found)) {
|
|
616
|
+
return refuse(response, who, 404, "no such run", "/runs");
|
|
718
617
|
}
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
618
|
+
const taskId = store.externalIdFor(found.taskRef) ?? "?";
|
|
619
|
+
const running = runIsLive(found);
|
|
620
|
+
if (url.searchParams.get("fragment") === "check") {
|
|
621
|
+
response.setHeader("cache-control", "no-store");
|
|
622
|
+
return respond(response, 200, "text/html; charset=utf-8", checkProgressHtml(store.checkProgress(found.id)));
|
|
623
|
+
}
|
|
624
|
+
if (url.searchParams.get("fragment") === "facts") {
|
|
625
|
+
// The facts region alone — same auth and ceiling as the page; a
|
|
626
|
+
// finished or no-longer-live run says so rather than growing forms
|
|
627
|
+
// (finding 5; round-4 finding 15).
|
|
628
|
+
return respond(response, 200, "text/html; charset=utf-8", runFactsFragment(found, taskId, running, store.runRoute(found.id)));
|
|
629
|
+
}
|
|
630
|
+
if (url.searchParams.get("fragment") === "peek") {
|
|
631
|
+
// The live peek: cookie sessions only (v2 §3), never stored, never
|
|
632
|
+
// cached by anything downstream.
|
|
633
|
+
if (who.via !== "cookie")
|
|
634
|
+
return respond(response, 403, "text/plain; charset=utf-8", "the live peek is a browser session's view");
|
|
635
|
+
const peeked = await peekFragment(found.id, who.session.csrf, options.editorLinks !== undefined && found.runner === options.localRunner && who.session.editorLinks === true);
|
|
636
|
+
response.setHeader("cache-control", "no-store");
|
|
637
|
+
if (peeked.retryAfter !== undefined)
|
|
638
|
+
response.setHeader("retry-after", String(peeked.retryAfter));
|
|
639
|
+
return respond(response, peeked.status, "text/html; charset=utf-8", peeked.body);
|
|
640
|
+
}
|
|
641
|
+
if (url.searchParams.get("fragment") === "transcript") {
|
|
642
|
+
// The live transcript window (arc 1): raw sanitized TEXT as JSON —
|
|
643
|
+
// the sink is textContent, never innerHTML. Guards, enumerated:
|
|
644
|
+
// cookie session; run visible (proved above); this machine's
|
|
645
|
+
// runner asserted; byte offsets validated; the file read through
|
|
646
|
+
// its own descriptor with the exact numeric-id name. Display
|
|
647
|
+
// state, not evidence — and never cached.
|
|
648
|
+
response.setHeader("cache-control", "no-store");
|
|
649
|
+
if (who.via !== "cookie")
|
|
650
|
+
return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
|
|
651
|
+
if (options.localRunner === undefined) {
|
|
652
|
+
return respond(response, 200, "application/json", JSON.stringify({ error: "off" }));
|
|
653
|
+
}
|
|
654
|
+
const fromRaw = url.searchParams.get("from") ?? "0";
|
|
655
|
+
const from = /^[0-9]{1,15}$/.test(fromRaw) ? Number(fromRaw) : Number.NaN;
|
|
656
|
+
if (!Number.isSafeInteger(from) || from < 0) {
|
|
657
|
+
return respond(response, 400, "application/json", JSON.stringify({ error: "offset" }));
|
|
658
|
+
}
|
|
659
|
+
const live = runIsLive(found);
|
|
660
|
+
// The final drain: a finished run's tail — including a torn last
|
|
661
|
+
// line — may be read until the sweep removes the file.
|
|
662
|
+
const window = readLiveWindow(evidenceRoot, found.id, from, !live);
|
|
663
|
+
if (!window.ok) {
|
|
664
|
+
if (window.reason === "replaced") {
|
|
665
|
+
return respond(response, 409, "application/json", JSON.stringify({ error: "replaced" }));
|
|
666
|
+
}
|
|
667
|
+
// Missing or unreadable: nothing to show. Final only when the
|
|
668
|
+
// run can never write again.
|
|
669
|
+
return respond(response, 200, "application/json", JSON.stringify({ text: "", nextOffset: from, final: !live }));
|
|
670
|
+
}
|
|
671
|
+
// Re-proof after the read (peek discipline): the run row still says
|
|
672
|
+
// what admission said, or nothing is shown.
|
|
673
|
+
const again = store.getRun(found.id);
|
|
674
|
+
if (again === null || !runVisible(again)) {
|
|
675
|
+
return respond(response, 200, "application/json", JSON.stringify({ text: "", nextOffset: from, final: true }));
|
|
676
|
+
}
|
|
677
|
+
return respond(response, 200, "application/json", JSON.stringify({ text: window.text, nextOffset: window.nextOffset, final: !live && window.eof }));
|
|
722
678
|
}
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
679
|
+
// One result page (2026-10-02): a builder's finished result opens on
|
|
680
|
+
// its task's result page, titled with the task. The raw run record
|
|
681
|
+
// waits there under Details, and here with ?record=1.
|
|
682
|
+
if (url.searchParams.get("record") === null && !running && found.role === "builder" && (found.outcome === "built" || found.outcome === "no-change") &&
|
|
683
|
+
completedRowFor(taskId, null)?.runId === found.id) {
|
|
684
|
+
const tab = parseResultTab(url.searchParams.get("tab"));
|
|
685
|
+
return redirect(response, `${reviewHref(taskId, found.id)}${tab === "summary" ? "" : `&tab=${tab}`}`);
|
|
686
|
+
}
|
|
687
|
+
// A build that failed or stopped opens on its own result page too: its changes, its checks and what went wrong.
|
|
688
|
+
if (url.searchParams.get("record") === null && !running && (found.role === "builder" || found.role === "scout") && attemptRowFor(taskId, found.id, null) !== null) {
|
|
689
|
+
const tab = parseResultTab(url.searchParams.get("tab"));
|
|
690
|
+
return redirect(response, `${reviewHref(taskId, found.id)}${tab === "summary" ? "" : `&tab=${tab}`}`);
|
|
691
|
+
}
|
|
692
|
+
// Pollers exist only for a LIVE run — an orphaned null-outcome run
|
|
693
|
+
// would otherwise be refetched forever (finding 15). The nonce is
|
|
694
|
+
// sendScreen's business now.
|
|
695
|
+
const artifacts = store.artifactsFor(found.id);
|
|
696
|
+
return sendScreen(response, 200, runPage(chromeFor(project, "runs", runListPane(project, found.id)), found, taskId, artifacts, terminalDiffView(artifacts, evidenceRoot), store.notesForRun(found.id), who.via === "cookie" ? who.session.csrf : "", store.liveDiffComments(found.id).filter(isRevisionFeedback), (() => {
|
|
697
|
+
const publication = store.publicationForRun(found.id);
|
|
698
|
+
return publication !== null && publication.prNumber !== null && store.hasOpenCiEpisode(publication.githubRepo, publication.prNumber)
|
|
699
|
+
? { pr: publication.prNumber }
|
|
700
|
+
: null;
|
|
701
|
+
})(), !running
|
|
702
|
+
? undefined
|
|
703
|
+
: regionScript("run-facts", "facts") +
|
|
704
|
+
(options.localRunner === undefined ? "" : regionScript("run-peek", "peek")) +
|
|
705
|
+
(options.localRunner === undefined ? "" : transcriptScript()), options.localRunner !== undefined, running,
|
|
706
|
+
// Editor links (arc 6): three statements align or nothing renders —
|
|
707
|
+
// the deployment capability, THIS machine's runner owning the run,
|
|
708
|
+
// and the session's own device-side yes.
|
|
709
|
+
options.editorLinks !== undefined &&
|
|
710
|
+
options.localRunner !== undefined &&
|
|
711
|
+
found.runner === options.localRunner &&
|
|
712
|
+
who.via === "cookie" &&
|
|
713
|
+
who.session.editorLinks === true &&
|
|
714
|
+
// A reviewer run (v29) never had a checkout — no files to open.
|
|
715
|
+
found.worktree !== null
|
|
716
|
+
? { worktree: found.worktree }
|
|
717
|
+
: null, options.editorLinks !== undefined &&
|
|
718
|
+
options.localRunner !== undefined &&
|
|
719
|
+
found.runner === options.localRunner &&
|
|
720
|
+
who.via === "cookie"
|
|
721
|
+
? { on: who.session.editorLinks === true }
|
|
722
|
+
: null, url.searchParams.get("noted") !== null, structuredHandoffView(artifacts, evidenceRoot), proofBundleView(store, found, artifacts, evidenceRoot), store.runRoute(found.id), store.publicationForRun(found.id), reviewFactsFor(found.id),
|
|
723
|
+
// The shared result presentation (package 3) for a finished result.
|
|
724
|
+
!running && (found.outcome === "built" || found.outcome === "no-change")
|
|
725
|
+
? { detail: resultDetailOf(found, who, now), tab: parseResultTab(url.searchParams.get("tab")), user: who.name, requestToken: randomBytes(16).toString("hex") }
|
|
726
|
+
: null, store.getScope(taskId)?.digest ?? null));
|
|
727
|
+
}
|
|
728
|
+
async function showRunEvidence(ctx) {
|
|
729
|
+
const { url, who, response } = ctx;
|
|
730
|
+
const runArtifact = /^\/r\/([0-9]{1,15})\/evidence\/([0-9]{1,15})$/.exec(url.pathname);
|
|
731
|
+
if (runArtifact === null)
|
|
732
|
+
return noPage(who, response);
|
|
733
|
+
return runEvidence(response, Number(runArtifact[1]), Number(runArtifact[2]));
|
|
734
|
+
}
|
|
735
|
+
// v115: routines became scheduled flows; an old link to the list or to one routine lands on the flows page.
|
|
736
|
+
async function showRoutines(ctx) {
|
|
737
|
+
return redirect(ctx.response, "/flows");
|
|
738
|
+
}
|
|
739
|
+
async function showRoutine(ctx) {
|
|
740
|
+
return redirect(ctx.response, "/flows");
|
|
741
|
+
}
|
|
742
|
+
async function showDecision(ctx) {
|
|
743
|
+
const { url, who, response, now, project } = ctx;
|
|
726
744
|
const one = /^\/d\/([0-9]{1,15})$/.exec(url.pathname);
|
|
727
|
-
if (one
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const taskId = taskOf(store, decision);
|
|
736
|
-
const decisionReturn = url.searchParams.get("return");
|
|
737
|
-
const back = decisionReturn === null ? null : safeChatReturn(decisionReturn);
|
|
738
|
-
return sendScreen(response, 200, decisionPage(chromeFor(project, "none"), decision, taskId, store.evidenceFor(decision.id), who, now, back));
|
|
745
|
+
if (one === null)
|
|
746
|
+
return noPage(who, response);
|
|
747
|
+
const decision = store.getDecision(Number(one[1]));
|
|
748
|
+
if (decision === null)
|
|
749
|
+
return refuse(response, who, 404, "no such decision");
|
|
750
|
+
const run = store.getRun(decision.run);
|
|
751
|
+
if (run !== null && !visible(taskRepoOf(run.taskRef))) {
|
|
752
|
+
return refuse(response, who, 404, "no such decision");
|
|
739
753
|
}
|
|
754
|
+
const taskId = taskOf(store, decision);
|
|
755
|
+
const decisionReturn = url.searchParams.get("return");
|
|
756
|
+
const back = decisionReturn === null ? null : safeChatReturn(decisionReturn);
|
|
757
|
+
return sendScreen(response, 200, decisionPage(chromeFor(project, "none"), decision, taskId, store.evidenceFor(decision.id), who, now, back));
|
|
758
|
+
}
|
|
759
|
+
async function showDecisionEvidence(ctx) {
|
|
760
|
+
const { url, who, response } = ctx;
|
|
740
761
|
const artifact = /^\/d\/([0-9]{1,15})\/evidence\/([0-9]{1,15})$/.exec(url.pathname);
|
|
741
|
-
if (artifact
|
|
742
|
-
return
|
|
743
|
-
|
|
744
|
-
return refuse(response, who, 404, "There's no page at this address.", "/chat");
|
|
762
|
+
if (artifact === null)
|
|
763
|
+
return noPage(who, response);
|
|
764
|
+
return decisionEvidence(response, Number(artifact[1]), Number(artifact[2]));
|
|
745
765
|
}
|
|
746
|
-
|
|
747
|
-
const { url, who, request, response,
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
766
|
+
const codeAct = (verb) => async (ctx) => {
|
|
767
|
+
const { url, who, request, response, posted } = ctx;
|
|
768
|
+
const body = readForm(posted, CONSOLE_FORMS.code);
|
|
769
|
+
const wantsJson = request.headers.accept?.includes('application/json') === true;
|
|
770
|
+
const fail = (status, message, delivery = 'rejected', sessionId) => wantsJson
|
|
771
|
+
? respond(response, status, 'application/json', JSON.stringify({ ok: false, error: message, delivery, ...(sessionId ? { sessionId } : {}) }))
|
|
772
|
+
: refuse(response, who, status, message, '/code');
|
|
773
|
+
if (who.via !== 'cookie' || !codingActorAllowed({ name: who.name, generation: who.session.generation }) || store.isDemo())
|
|
774
|
+
return fail(403, 'Coding sessions require installation-wide operator access. You can manage project tasks in Work.');
|
|
775
|
+
if (!runtime.coding)
|
|
776
|
+
return fail(409, runtime.codingProblem || 'Open Toolroll on the machine with your installed coding agent.');
|
|
777
|
+
if ([...new Set(body.keys())].some(key => body.sent.getAll(key).length !== 1))
|
|
778
|
+
return fail(400, 'Submit one value for each field.');
|
|
779
|
+
const actor = { name: who.name, generation: who.session.generation };
|
|
780
|
+
const permitted = (repo) => visible(repo) && codingProjectAllowed(repo);
|
|
781
|
+
const starting = verb === 'start';
|
|
782
|
+
const sessionId = starting || verb === 'unknown' ? null : /^\/code\/([a-f0-9]{32})/.exec(url.pathname)[1];
|
|
783
|
+
// Sprint 8: a coding session is Codex taking turns: the organisation policy decides whether it may (provider,
|
|
784
|
+
// model, and a ceiling Codex can't run under), when it starts and on every action that makes it take another turn.
|
|
785
|
+
const turnAction = ['send', 'resume', 'recover', 'continue', 'answer'].includes(verb);
|
|
786
|
+
if (starting || turnAction) {
|
|
787
|
+
const stopped = store.sessionPolicyRefusal('codex', starting ? body.get('model')?.trim() || null : null, 'coding sessions');
|
|
788
|
+
if (stopped !== null)
|
|
789
|
+
return fail(403, stopped);
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
let id;
|
|
793
|
+
if (starting) {
|
|
794
|
+
const repo = body.get('repo') ?? '';
|
|
795
|
+
if (!permitted(repo))
|
|
796
|
+
return fail(403, 'Choose a project available to your account.');
|
|
797
|
+
if (!authenticateApprover(who, body.get('password') ?? '').ok)
|
|
798
|
+
return fail(403, 'Enter your Toolroll password to authorize this coding session.');
|
|
799
|
+
const session = await runtime.coding.start(actor, { repo, title: body.get('title') ?? '', model: body.get('model')?.trim() || null, prompt: body.get('prompt') ?? '', requestId: body.get('requestId') ?? '' });
|
|
800
|
+
id = session.id;
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
if (sessionId === null)
|
|
804
|
+
return fail(404, 'Coding action not found.');
|
|
805
|
+
id = sessionId;
|
|
806
|
+
const session = runtime.coding.get(id, actor);
|
|
807
|
+
if (!permitted(session.repo))
|
|
808
|
+
return fail(403, 'That project is outside your access.');
|
|
809
|
+
if (verb === 'ship') {
|
|
810
|
+
const made = createCodingHandoff(store, { sessionId: id, actor: who.name, repo: session.repo, base: body.get('base') ?? '', candidate: body.get('candidate') ?? '', title: body.get('title') ?? '', goal: body.get('goal') ?? '', acceptance: (body.get('acceptance') ?? '').split('\n').map(line => line.trim()).filter(Boolean).map((statement, index) => ({ id: `c${index + 1}`, statement, evidence: ['check', 'changed-path', ...(body.get('visual') === 'yes' ? ['screenshot'] : [])] })) });
|
|
811
|
+
return wantsJson ? respond(response, 200, 'application/json', JSON.stringify({ ok: true, taskId: made.taskId })) : redirect(response, `/t/${encodeURIComponent(made.taskId)}`);
|
|
812
|
+
}
|
|
813
|
+
if (verb === 'send')
|
|
814
|
+
await runtime.coding.send(id, actor, body.get('prompt') ?? '', body.get('requestId') ?? '');
|
|
815
|
+
else if (verb === 'stop')
|
|
816
|
+
await runtime.coding.stop(id, actor);
|
|
817
|
+
else if (verb === 'resume')
|
|
818
|
+
await runtime.coding.resume(id, actor);
|
|
819
|
+
else if (verb === 'recover')
|
|
820
|
+
await runtime.coding.recover(id, actor);
|
|
821
|
+
else if (verb === 'continue')
|
|
822
|
+
await runtime.coding.continueSaved(id, actor);
|
|
781
823
|
else {
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const
|
|
787
|
-
if (
|
|
788
|
-
return fail(
|
|
789
|
-
if (
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
else if (match[2] === 'recover')
|
|
800
|
-
await runtime.coding.recover(id, actor);
|
|
801
|
-
else if (match[2] === 'continue')
|
|
802
|
-
await runtime.coding.continueSaved(id, actor);
|
|
803
|
-
else {
|
|
804
|
-
const token = body.get('requestId') ?? '';
|
|
805
|
-
const pending = runtime.coding.snapshot(id, actor).requests.find(r => r.id === token);
|
|
806
|
-
if (!pending)
|
|
807
|
-
return fail(409, 'This request is no longer waiting for an answer.');
|
|
808
|
-
const decision = body.get('decision');
|
|
809
|
-
if (decision !== 'accept' && decision !== 'decline' && decision !== 'cancel')
|
|
810
|
-
return fail(400, 'Choose an explicit decision for this request.');
|
|
811
|
-
if (pending.kind !== 'questions' && decision === 'accept' && !authenticateApprover(who, body.get('password') ?? '').ok)
|
|
812
|
-
return fail(403, 'Enter your password to approve this additional access.');
|
|
813
|
-
const answers = body.has('answers') ? JSON.parse(body.get('answers')) : Object.fromEntries(pending.questions.map(q => [q.id, { answers: [body.get(`question:${q.id}`) ?? ''] }]));
|
|
814
|
-
runtime.coding.answer(id, actor, token, decision, answers);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
// Authorization is checked again after asynchronous startup/transport work.
|
|
818
|
-
if (!codingActorAllowed(actor))
|
|
819
|
-
return fail(403, 'Your access changed. Sign in again.', 'unknown', id);
|
|
820
|
-
return wantsJson ? respond(response, 200, 'application/json', JSON.stringify({ ok: true, id })) : redirect(response, `/code/${id}`);
|
|
821
|
-
}
|
|
822
|
-
catch (error) {
|
|
823
|
-
if (!codingActorAllowed(actor))
|
|
824
|
-
return fail(403, 'Your access changed. Sign in again.', error instanceof CodingActionError ? error.delivery : 'rejected', error instanceof CodingActionError ? error.sessionId : undefined);
|
|
825
|
-
const message = error instanceof Error ? error.message : 'The coding action could not finish. Your draft is preserved.';
|
|
826
|
-
const ship = /^\/code\/([a-f0-9]{32})\/ship$/.exec(url.pathname);
|
|
827
|
-
if (ship && !wantsJson) {
|
|
828
|
-
try {
|
|
829
|
-
const preview = codingHandoffPreview(store, { sessionId: ship[1], actor: who.name });
|
|
830
|
-
return sendScreen(response, 409, screen('Review for shipping', codingShippingHtml(preview, who.session.csrf, body.sent, message), { chrome: chromeFor(preview.repo, 'code') }));
|
|
831
|
-
}
|
|
832
|
-
catch { }
|
|
833
|
-
}
|
|
834
|
-
return fail(409, message, error instanceof CodingActionError ? error.delivery : 'rejected', error instanceof CodingActionError ? error.sessionId : undefined);
|
|
835
|
-
}
|
|
824
|
+
const token = body.get('requestId') ?? '';
|
|
825
|
+
const pending = runtime.coding.snapshot(id, actor).requests.find(r => r.id === token);
|
|
826
|
+
if (!pending)
|
|
827
|
+
return fail(409, 'This request is no longer waiting for an answer.');
|
|
828
|
+
const decision = body.get('decision');
|
|
829
|
+
if (decision !== 'accept' && decision !== 'decline' && decision !== 'cancel')
|
|
830
|
+
return fail(400, 'Choose an explicit decision for this request.');
|
|
831
|
+
if (pending.kind !== 'questions' && decision === 'accept' && !authenticateApprover(who, body.get('password') ?? '').ok)
|
|
832
|
+
return fail(403, 'Enter your password to approve this additional access.');
|
|
833
|
+
const answers = body.has('answers') ? JSON.parse(body.get('answers')) : Object.fromEntries(pending.questions.map(q => [q.id, { answers: [body.get(`question:${q.id}`) ?? ''] }]));
|
|
834
|
+
runtime.coding.answer(id, actor, token, decision, answers);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// Authorization is checked again after asynchronous startup/transport work.
|
|
838
|
+
if (!codingActorAllowed(actor))
|
|
839
|
+
return fail(403, 'Your access changed. Sign in again.', 'unknown', id);
|
|
840
|
+
return wantsJson ? respond(response, 200, 'application/json', JSON.stringify({ ok: true, id })) : redirect(response, `/code/${id}`);
|
|
836
841
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
if (who.via === "cookie") {
|
|
846
|
-
const seen = body.get("projectRevision");
|
|
847
|
-
if (seen !== null && seen !== String(who.session.projectRevision)) {
|
|
848
|
-
return refuse(response, who, 409, "the open project changed since this form was rendered — reload and try again", "/tasks");
|
|
842
|
+
catch (error) {
|
|
843
|
+
if (!codingActorAllowed(actor))
|
|
844
|
+
return fail(403, 'Your access changed. Sign in again.', error instanceof CodingActionError ? error.delivery : 'rejected', error instanceof CodingActionError ? error.sessionId : undefined);
|
|
845
|
+
const message = error instanceof Error ? error.message : 'The coding action could not finish. Your draft is preserved.';
|
|
846
|
+
if (verb === 'ship' && !wantsJson) {
|
|
847
|
+
try {
|
|
848
|
+
const preview = codingHandoffPreview(store, { sessionId: sessionId, actor: who.name });
|
|
849
|
+
return sendScreen(response, 409, screen('Review for shipping', codingShippingHtml(preview, body.sent, message), { chrome: chromeFor(preview.repo, 'code') }));
|
|
849
850
|
}
|
|
851
|
+
catch { }
|
|
850
852
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
// under a ceiling. Unscoped mode keeps its historic unplaced filings.
|
|
868
|
-
if (!unscopedMode && effective === "") {
|
|
869
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
870
|
-
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()));
|
|
871
|
-
}
|
|
872
|
-
let repo = effective;
|
|
873
|
-
let admitted = unscopedMode ? null : admissionList() ?? [];
|
|
874
|
-
const rootMode = !unscopedMode && ceiling.roots.length > 0;
|
|
875
|
-
if (rootMode && effective !== "") {
|
|
876
|
-
const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
|
|
877
|
-
if (canonical === null || canonical === undefined) {
|
|
878
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
879
|
-
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()));
|
|
880
|
-
}
|
|
881
|
-
repo = canonical;
|
|
882
|
-
admitted = [canonical];
|
|
883
|
-
}
|
|
884
|
-
const goal = body.get("goal") ?? "";
|
|
885
|
-
const notThis = body.get("not") ?? "";
|
|
886
|
-
const touchesGiven = (body.get("touches") ?? "")
|
|
887
|
-
.split(/[\n,]/)
|
|
888
|
-
.map(one => one.trim())
|
|
889
|
-
.filter(one => one !== "");
|
|
890
|
-
const scout = body.get("scout") === "1";
|
|
891
|
-
const permissionMode = body.get("permission-mode");
|
|
892
|
-
if (permissionMode !== null && permissionMode !== "auto" && permissionMode !== "bypassPermissions") {
|
|
893
|
-
return refuse(response, who, 400, "permissions must be Auto or Full access", "/tasks/new");
|
|
894
|
-
}
|
|
895
|
-
const qualityMode = body.get("quality-mode");
|
|
896
|
-
if (qualityMode !== null && !isQualityMode(qualityMode)) {
|
|
897
|
-
return refuse(response, who, 400, "quality must be Default or Strict / release", "/tasks/new");
|
|
898
|
-
}
|
|
899
|
-
// One filing door for every surface (Codex adoption review, finding 7).
|
|
900
|
-
const after = (body.get("after") ?? "").trim();
|
|
901
|
-
let chainProblem = null;
|
|
902
|
-
const made = store.transact(() => {
|
|
903
|
-
const filed = fileTaskProposal(store, {
|
|
904
|
-
...(id === "" ? {} : { id }),
|
|
905
|
-
title,
|
|
906
|
-
...(repo === "" ? {} : { repo }),
|
|
907
|
-
...(goal === "" ? {} : { goal, acceptance: acceptanceLinesToInput((body.get("acceptance") ?? "").split("\n")) }),
|
|
908
|
-
outOfScope: notThis === "" ? null : notThis,
|
|
909
|
-
touches: touchesGiven,
|
|
910
|
-
...(permissionMode === null ? {} : { permissionMode }),
|
|
911
|
-
...(qualityMode === null ? {} : { qualityMode }),
|
|
912
|
-
...(scout ? { deliverable: "report" } : {}),
|
|
913
|
-
planning: scout
|
|
914
|
-
? "skip"
|
|
915
|
-
: body.get("planning-policy") === "choice"
|
|
916
|
-
? body.get("plan-first") === "1" ? "required" : "skip"
|
|
917
|
-
: "auto",
|
|
918
|
-
filedVia: "console", filedBy: { name: who.name, kind: "person" },
|
|
919
|
-
...(admitted === null ? {} : { admittedRepos: admitted }),
|
|
920
|
-
}, now);
|
|
921
|
-
if (filed.ok && after !== "") {
|
|
922
|
-
const afterRef = store.lookupRef(after);
|
|
923
|
-
const chained = store.getTask(after) !== null && afterRef !== null && visible(afterRef.repo)
|
|
924
|
-
? store.addEdge(filed.id, after) : { ok: false, reason: "that task does not exist here" };
|
|
925
|
-
if (!chained.ok)
|
|
926
|
-
chainProblem = chained.reason;
|
|
927
|
-
}
|
|
928
|
-
// Missing dependencies leave a reviewable, inert task. They must
|
|
929
|
-
// never be silently dropped before automatic approval starts work.
|
|
930
|
-
if (filed.ok && chainProblem === null && who.via === "cookie")
|
|
931
|
-
applyModeToNewFiling(store, filed.id, who.name, now);
|
|
932
|
-
return filed;
|
|
933
|
-
});
|
|
934
|
-
if (!made.ok) {
|
|
935
|
-
const csrf = who.via === "cookie" ? who.session.csrf : "";
|
|
936
|
-
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.sent }, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
937
|
-
}
|
|
938
|
-
const actionContext = requestContext.getStore();
|
|
939
|
-
if (actionContext !== undefined)
|
|
940
|
-
actionContext.createdTask = made.id;
|
|
941
|
-
// A proved root-mode placement joins the project table (finding 15):
|
|
942
|
-
// the new task's home is openable and admissible from now on.
|
|
943
|
-
if (rootMode && repo !== "")
|
|
944
|
-
store.upsertProject(repo, projectName(repo), now);
|
|
945
|
-
if (chainProblem !== null)
|
|
946
|
-
return taskScreen(response, who, made.id, `the task was created, but could not be made to wait for ${after} — ${chainProblem}`, 200);
|
|
947
|
-
return redirect(response, taskHref(made.id));
|
|
948
|
-
}
|
|
949
|
-
if (url.pathname === "/queue/move" || url.pathname === "/queue/note") {
|
|
950
|
-
const body = readForm(posted, CONSOLE_FORMS.queue);
|
|
951
|
-
const project = projectOf(who, request);
|
|
952
|
-
if (project === undefined) {
|
|
953
|
-
return refuse(response, who, 403, "that project is outside what this server was configured to show");
|
|
954
|
-
}
|
|
955
|
-
// The note is a global runner label, not project-scoped work: the
|
|
956
|
-
// fleet screen (project-less) posts it too, so the null-project gate
|
|
957
|
-
// applies only to moves, which the task-level check below re-proves.
|
|
958
|
-
if (url.pathname === "/queue/note") {
|
|
959
|
-
const worker = (body.get("runner") ?? "").trim();
|
|
960
|
-
const note = (body.get("note") ?? "").trim();
|
|
961
|
-
if (note.length > 200 || hasForbiddenControls(note)) {
|
|
962
|
-
return refuse(response, who, 400, "that note will not render, so it will not store", QUEUE_VIEW);
|
|
963
|
-
}
|
|
964
|
-
const set = store.setRunnerQueueNote(worker, note === "" ? null : note);
|
|
965
|
-
if (!set.ok)
|
|
966
|
-
return refuse(response, who, 404, "no such worker", QUEUE_VIEW);
|
|
967
|
-
return redirect(response, body.get("from") === "fleet" ? "/fleet" : QUEUE_VIEW);
|
|
853
|
+
return fail(409, message, error instanceof CodingActionError ? error.delivery : 'rejected', error instanceof CodingActionError ? error.sessionId : undefined);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
async function addTask(ctx) {
|
|
857
|
+
const { who, request, response, now, posted } = ctx;
|
|
858
|
+
const body = readForm(posted, CONSOLE_FORMS.tasksAdd);
|
|
859
|
+
const project = projectOf(who, request);
|
|
860
|
+
if (project === undefined) {
|
|
861
|
+
return refuse(response, who, 403, "that project is outside what this server was configured to show");
|
|
862
|
+
}
|
|
863
|
+
// Stale-tab guard: a form rendered under one open project must not
|
|
864
|
+
// create into a different one switched-to since (finding 6).
|
|
865
|
+
if (who.via === "cookie") {
|
|
866
|
+
const seen = body.get("projectRevision");
|
|
867
|
+
if (seen !== null && seen !== String(who.session.projectRevision)) {
|
|
868
|
+
return refuse(response, who, 409, "the open project changed since this form was rendered — reload and try again", "/tasks");
|
|
968
869
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
870
|
+
}
|
|
871
|
+
const id = (body.get("id") ?? "").trim();
|
|
872
|
+
const title = body.get("title") ?? "";
|
|
873
|
+
// The EFFECTIVE placement (repo onboarding, findings 15/35): the
|
|
874
|
+
// trimmed, nonempty posted repo, else the open project — an empty
|
|
875
|
+
// input falls through correctly. In root mode the effective path is
|
|
876
|
+
// proved by authorizedProject EXACTLY as typed-then-canonicalized,
|
|
877
|
+
// and [canonical] is the admitted list — a fresh clone under a root
|
|
878
|
+
// must be able to receive its first task without waiting to appear
|
|
879
|
+
// in any table.
|
|
880
|
+
const repoGiven = (body.get("repo") ?? "").trim();
|
|
881
|
+
const effective = repoGiven !== "" ? repoGiven : (project ?? "");
|
|
882
|
+
if (restricted() && !visible(effective === "" ? null : effective))
|
|
883
|
+
return refuse(response, who, 403, "That project is outside your access.", "/projects");
|
|
884
|
+
// A scoped console refuses an EMPTY placement server-side (verification
|
|
885
|
+
// finding 1): the form's `required` is a courtesy, not the guard — a
|
|
886
|
+
// direct POST with no project open must not mint an unplaced task
|
|
887
|
+
// under a ceiling. Unscoped mode keeps its historic unplaced filings.
|
|
888
|
+
if (!unscopedMode && effective === "") {
|
|
889
|
+
return sendScreen(response, 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, "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()));
|
|
890
|
+
}
|
|
891
|
+
let repo = effective;
|
|
892
|
+
let admitted = unscopedMode ? null : admissionList() ?? [];
|
|
893
|
+
const rootMode = !unscopedMode && ceiling.roots.length > 0;
|
|
894
|
+
if (rootMode && effective !== "") {
|
|
895
|
+
const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
|
|
896
|
+
if (canonical === null || canonical === undefined) {
|
|
897
|
+
return sendScreen(response, 403, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, `${effective} is outside what this server was configured to show`, project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
898
|
+
}
|
|
899
|
+
repo = canonical;
|
|
900
|
+
admitted = [canonical];
|
|
901
|
+
}
|
|
902
|
+
const goal = body.get("goal") ?? "";
|
|
903
|
+
const notThis = body.get("not") ?? "";
|
|
904
|
+
const touchesGiven = (body.get("touches") ?? "")
|
|
905
|
+
.split(/[\n,]/)
|
|
906
|
+
.map(one => one.trim())
|
|
907
|
+
.filter(one => one !== "");
|
|
908
|
+
const scout = body.get("scout") === "1";
|
|
909
|
+
const permissionMode = body.get("permission-mode");
|
|
910
|
+
if (permissionMode !== null && permissionMode !== "auto" && permissionMode !== "bypassPermissions") {
|
|
911
|
+
return refuse(response, who, 400, "permissions must be Auto or Full access", "/tasks/new");
|
|
912
|
+
}
|
|
913
|
+
const qualityMode = body.get("quality-mode");
|
|
914
|
+
if (qualityMode !== null && !isQualityMode(qualityMode)) {
|
|
915
|
+
return refuse(response, who, 400, "quality must be Default or Strict / release", "/tasks/new");
|
|
916
|
+
}
|
|
917
|
+
// One filing door for every surface (Codex adoption review, finding 7).
|
|
918
|
+
const after = (body.get("after") ?? "").trim();
|
|
919
|
+
let chainProblem = null;
|
|
920
|
+
const made = store.transact(() => {
|
|
921
|
+
const filed = fileTaskProposal(store, {
|
|
922
|
+
...(id === "" ? {} : { id }),
|
|
923
|
+
title,
|
|
924
|
+
...(repo === "" ? {} : { repo }),
|
|
925
|
+
...(goal === "" ? {} : { goal, acceptance: acceptanceLinesToInput((body.get("acceptance") ?? "").split("\n")) }),
|
|
926
|
+
outOfScope: notThis === "" ? null : notThis,
|
|
927
|
+
touches: touchesGiven,
|
|
928
|
+
...(permissionMode === null ? {} : { permissionMode }),
|
|
929
|
+
...(qualityMode === null ? {} : { qualityMode }),
|
|
930
|
+
...(scout ? { deliverable: "report" } : {}),
|
|
931
|
+
planning: scout
|
|
932
|
+
? "skip"
|
|
933
|
+
: body.get("planning-policy") === "choice"
|
|
934
|
+
? body.get("plan-first") === "1" ? "required" : "skip"
|
|
935
|
+
: "auto",
|
|
936
|
+
filedVia: "console", filedBy: { name: who.name, kind: "person" },
|
|
937
|
+
...(admitted === null ? {} : { admittedRepos: admitted }),
|
|
938
|
+
}, now);
|
|
939
|
+
if (filed.ok && after !== "") {
|
|
940
|
+
const afterRef = store.lookupRef(after);
|
|
941
|
+
const chained = store.getTask(after) !== null && afterRef !== null && visible(afterRef.repo)
|
|
942
|
+
? store.addEdge(filed.id, after) : { ok: false, reason: "that task does not exist here" };
|
|
943
|
+
if (!chained.ok)
|
|
944
|
+
chainProblem = chained.reason;
|
|
945
|
+
}
|
|
946
|
+
// Missing dependencies leave a reviewable, inert task. They must
|
|
947
|
+
// never be silently dropped before automatic approval starts work.
|
|
948
|
+
if (filed.ok && chainProblem === null && who.via === "cookie")
|
|
949
|
+
applyModeToNewFiling(store, filed.id, who.name, now);
|
|
950
|
+
return filed;
|
|
951
|
+
});
|
|
952
|
+
if (!made.ok) {
|
|
953
|
+
return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent }, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
|
|
954
|
+
}
|
|
955
|
+
const actionContext = requestContext.getStore();
|
|
956
|
+
if (actionContext !== undefined)
|
|
957
|
+
actionContext.createdTask = made.id;
|
|
958
|
+
// A proved root-mode placement joins the project table (finding 15):
|
|
959
|
+
// the new task's home is openable and admissible from now on.
|
|
960
|
+
if (rootMode && repo !== "")
|
|
961
|
+
store.upsertProject(repo, projectName(repo), now);
|
|
962
|
+
if (chainProblem !== null)
|
|
963
|
+
return taskScreen(response, who, made.id, `the task was created, but could not be made to wait for ${after} — ${chainProblem}`, 200);
|
|
964
|
+
return redirect(response, taskHref(made.id));
|
|
965
|
+
}
|
|
966
|
+
async function queueNote(ctx) {
|
|
967
|
+
const { who, request, response, posted } = ctx;
|
|
968
|
+
const body = readForm(posted, CONSOLE_FORMS.queue);
|
|
969
|
+
const project = projectOf(who, request);
|
|
970
|
+
if (project === undefined) {
|
|
971
|
+
return refuse(response, who, 403, "that project is outside what this server was configured to show");
|
|
972
|
+
}
|
|
973
|
+
// The note is a global runner label, not project-scoped work: the
|
|
974
|
+
// fleet screen (project-less) posts it too, so the null-project gate
|
|
975
|
+
// applies only to moves, which their task-level check re-proves.
|
|
976
|
+
const worker = (body.get("runner") ?? "").trim();
|
|
977
|
+
const note = (body.get("note") ?? "").trim();
|
|
978
|
+
if (note.length > 200 || hasForbiddenControls(note)) {
|
|
979
|
+
return refuse(response, who, 400, "that note will not render, so it will not store", QUEUE_VIEW);
|
|
980
|
+
}
|
|
981
|
+
const set = store.setRunnerQueueNote(worker, note === "" ? null : note);
|
|
982
|
+
if (!set.ok)
|
|
983
|
+
return refuse(response, who, 404, "no such worker", QUEUE_VIEW);
|
|
984
|
+
return redirect(response, body.get("from") === "fleet" ? "/fleet" : QUEUE_VIEW);
|
|
985
|
+
}
|
|
986
|
+
async function queueMove(ctx) {
|
|
987
|
+
const { who, request, response, posted } = ctx;
|
|
988
|
+
const body = readForm(posted, CONSOLE_FORMS.queue);
|
|
989
|
+
const project = projectOf(who, request);
|
|
990
|
+
if (project === undefined) {
|
|
991
|
+
return refuse(response, who, 403, "that project is outside what this server was configured to show");
|
|
992
|
+
}
|
|
993
|
+
if (who.via === "cookie") {
|
|
994
|
+
const seen = body.get("projectRevision");
|
|
995
|
+
if (seen !== null && seen !== String(who.session.projectRevision)) {
|
|
996
|
+
return refuse(response, who, 409, "the open project changed since this form was rendered — reload and try again", QUEUE_VIEW);
|
|
974
997
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
998
|
+
}
|
|
999
|
+
// A drag posts in place (fetch) when it can; the page re-renders its
|
|
1000
|
+
// own fragment on success. A plain form still works with no script.
|
|
1001
|
+
const inPlace = body.get("respond") === "fragment";
|
|
1002
|
+
const fromFleet = who.via === "cookie" && body.get("projectRevision") === null;
|
|
1003
|
+
const respondMove = (status, message) => {
|
|
1004
|
+
if (!inPlace)
|
|
1005
|
+
return status === 409 ? refuse(response, who, 409, message, QUEUE_VIEW) : redirect(response, QUEUE_VIEW);
|
|
1006
|
+
respond(response, status, "text/plain; charset=utf-8", message);
|
|
1007
|
+
};
|
|
1008
|
+
const moveReason = (reason) => reason === "stale"
|
|
1009
|
+
? "the queue moved underneath you — it just reloaded"
|
|
1010
|
+
: reason === "claimed"
|
|
1011
|
+
? "that task is being taken right now — it keeps its claim"
|
|
1012
|
+
: reason === "worker-retired"
|
|
1013
|
+
? "that worker is retired — drag its work elsewhere, or register the name again"
|
|
1014
|
+
: reason === "no-such-worker"
|
|
1015
|
+
? "no such worker"
|
|
1016
|
+
: "that task is not in this queue any more";
|
|
1017
|
+
const taskId = (body.get("task") ?? "").trim();
|
|
1018
|
+
const columnGiven = (body.get("column") ?? "").trim();
|
|
1019
|
+
const toRunner = columnGiven === "" || columnGiven === "anyone" ? null : columnGiven;
|
|
1020
|
+
const beforeGiven = (body.get("before") ?? "").trim();
|
|
1021
|
+
const revisionGiven = Number(body.get("queueRevision") ?? "");
|
|
1022
|
+
// The queue's own screen enforces the open project; the fleet screen
|
|
1023
|
+
// is cross-project, so the ceiling is the only wall it needs.
|
|
1024
|
+
const belongs = (id) => {
|
|
1025
|
+
const ref = store.lookupRef(id);
|
|
1026
|
+
return ref !== null && visible(ref.repo) && (fromFleet || project === null || ref.repo === null || ref.repo === project);
|
|
1027
|
+
};
|
|
1028
|
+
if (!belongs(taskId) || (beforeGiven !== "" && beforeGiven !== QUEUE_FRONT && !belongs(beforeGiven))) {
|
|
1029
|
+
return respondMove(404, "that task is not in this queue");
|
|
1030
|
+
}
|
|
1031
|
+
// The no-script "move to the front" button cannot name the front: the
|
|
1032
|
+
// front of a task's partition is decided by the store's exact repo AND
|
|
1033
|
+
// assignment, and the page's snapshot is bounded — so the sentinel is
|
|
1034
|
+
// resolved HERE, against a fresh snapshot, into a real task id (slice
|
|
1035
|
+
// 1b, fix 1). It is honored only within the task's own column; a
|
|
1036
|
+
// cross-column front is not provable from a bounded snapshot.
|
|
1037
|
+
let beforeTaskId = beforeGiven === "" ? null : beforeGiven;
|
|
1038
|
+
if (beforeGiven === QUEUE_FRONT) {
|
|
1039
|
+
const ref = store.lookupRef(taskId);
|
|
1040
|
+
if (ref === null)
|
|
1005
1041
|
return respondMove(404, "that task is not in this queue");
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
//
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
if (
|
|
1030
|
-
return respondMove(409, moveReason("
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
return respondMove(200, "already at the front");
|
|
1045
|
-
}
|
|
1046
|
-
beforeTaskId = front.id;
|
|
1047
|
-
}
|
|
1048
|
-
const moved = store.moveTask({
|
|
1049
|
-
taskId,
|
|
1050
|
-
toRunner,
|
|
1051
|
-
beforeTaskId,
|
|
1052
|
-
...(Number.isSafeInteger(revisionGiven) ? { queueRevision: revisionGiven } : {}),
|
|
1053
|
-
}, clock());
|
|
1054
|
-
if (!moved.ok) {
|
|
1055
|
-
return respondMove(409, moveReason(moved.reason));
|
|
1056
|
-
}
|
|
1057
|
-
return respondMove(200, "moved");
|
|
1042
|
+
if (ref.assignedRunner !== toRunner) {
|
|
1043
|
+
return respondMove(409, "move to the front works inside a task's own column — drag it across to reserve it elsewhere");
|
|
1044
|
+
}
|
|
1045
|
+
const now = clock();
|
|
1046
|
+
const snapshot = store.queueScoped(ref.repo, now);
|
|
1047
|
+
const self = snapshot.find(one => one.id === taskId);
|
|
1048
|
+
// A claim can land after the form rendered WITHOUT
|
|
1049
|
+
// bumping queueRevision, so the snapshot — not the form — decides
|
|
1050
|
+
// whether the card is still free. A taken or vanished card is the
|
|
1051
|
+
// typed refusal, never a silent no-op that would skip moveTask()'s
|
|
1052
|
+
// own claimed recheck.
|
|
1053
|
+
if (self === undefined)
|
|
1054
|
+
return respondMove(409, moveReason("unknown-task"));
|
|
1055
|
+
if (self.taken)
|
|
1056
|
+
return respondMove(409, moveReason("claimed"));
|
|
1057
|
+
const partition = snapshot.filter(one => one.repo === ref.repo && one.assignedRunner === ref.assignedRunner && !one.taken);
|
|
1058
|
+
const front = partition[0];
|
|
1059
|
+
if (front === undefined)
|
|
1060
|
+
return respondMove(409, moveReason("unknown-task"));
|
|
1061
|
+
if (front.id === taskId) {
|
|
1062
|
+
// Already the front: a no-op, but only against the revision the
|
|
1063
|
+
// form was rendered with — this branch never reaches moveTask()'s
|
|
1064
|
+
// CAS, so the check is made here.
|
|
1065
|
+
if (!Number.isSafeInteger(revisionGiven) || revisionGiven !== store.queueRevision()) {
|
|
1066
|
+
return respondMove(409, moveReason("stale"));
|
|
1067
|
+
}
|
|
1068
|
+
return respondMove(200, "already at the front");
|
|
1069
|
+
}
|
|
1070
|
+
beforeTaskId = front.id;
|
|
1071
|
+
}
|
|
1072
|
+
const moved = store.moveTask({
|
|
1073
|
+
taskId,
|
|
1074
|
+
toRunner,
|
|
1075
|
+
beforeTaskId,
|
|
1076
|
+
...(Number.isSafeInteger(revisionGiven) ? { queueRevision: revisionGiven } : {}),
|
|
1077
|
+
}, clock());
|
|
1078
|
+
if (!moved.ok) {
|
|
1079
|
+
return respondMove(409, moveReason(moved.reason));
|
|
1058
1080
|
}
|
|
1081
|
+
return respondMove(200, "moved");
|
|
1082
|
+
}
|
|
1083
|
+
async function answerDecision(ctx) {
|
|
1084
|
+
const { url, who, response, now, posted } = ctx;
|
|
1059
1085
|
const answer = /^\/d\/([0-9]{1,15})\/answer$/.exec(url.pathname);
|
|
1060
|
-
if (answer
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
const choice = body.get("choice") ?? "";
|
|
1077
|
-
const chosen = decision.options.find(option => option.id === choice);
|
|
1078
|
-
// Irreversible options never ride one accidental tap: the form arms
|
|
1079
|
-
// them behind an explicit confirmation field, and the server checks —
|
|
1080
|
-
// the client rendering is convenience, this is the rule.
|
|
1081
|
-
if (chosen !== undefined && !chosen.reversible && body.get("confirm") !== "yes") {
|
|
1082
|
-
return refuse(response, who, 400, "an irreversible choice must be confirmed", requestedReturn === null ? `/d/${id}` : `/d/${id}?return=${encodeURIComponent(decisionBack)}`);
|
|
1083
|
-
}
|
|
1084
|
-
const note = body.get("note");
|
|
1085
|
-
const answered = store.answerDecision({
|
|
1086
|
-
id,
|
|
1087
|
-
choice,
|
|
1088
|
-
by: who.name,
|
|
1089
|
-
via: "web",
|
|
1090
|
-
...(note === null || note === "" ? {} : { note }),
|
|
1091
|
-
}, now);
|
|
1092
|
-
if (!answered.ok) {
|
|
1093
|
-
const status = answered.reason === "bad-option" || answered.reason === "bad-note" ? 400 : 409;
|
|
1094
|
-
const why = answered.reason === "already-answered" ? "already answered — somebody got there first" : answered.reason;
|
|
1095
|
-
return refuse(response, who, status, why, requestedReturn === null ? `/d/${id}` : decisionBack);
|
|
1096
|
-
}
|
|
1097
|
-
return redirect(response, decisionBack);
|
|
1098
|
-
}
|
|
1099
|
-
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|confirm-stopped|stop|resume-arm|resume)$");
|
|
1100
|
-
if (act !== null && act.verb === "confirm-stopped") {
|
|
1101
|
-
const body = readForm(posted, CONSOLE_FORMS.confirmStopped);
|
|
1102
|
-
// Confirming a finished build stopped is an approver's act behind the password, typed again — the same
|
|
1103
|
-
// record as `toolroll run settle`, refused while anything of the run may still be running.
|
|
1104
|
-
const back = taskHref(act.taskId);
|
|
1105
|
-
if (who.via !== "cookie" || who.role !== "approver")
|
|
1106
|
-
return refuse(response, who, 403, "Only an approver can confirm a build stopped.", back);
|
|
1107
|
-
const ref = store.lookupRef(act.taskId);
|
|
1108
|
-
if (ref === null || !visible(ref.repo))
|
|
1109
|
-
return refuse(response, who, 404, "no such task", "/tasks");
|
|
1110
|
-
const named = (body.get("run") ?? "").trim();
|
|
1111
|
-
const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
|
|
1112
|
-
const runTask = run === null ? null : store.externalIdFor(run.taskRef);
|
|
1113
|
-
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
1114
|
-
return taskScreen(response, who, act.taskId, "That build isn't part of this task.", 409);
|
|
1115
|
-
}
|
|
1116
|
-
if (!authenticateApprover(who, body.get("token") ?? "", ref.repo).ok || !store.accountCanAccess(who.name, ref.repo)) {
|
|
1117
|
-
return taskScreen(response, who, act.taskId, "That password didn't match. Nothing changed.", 403);
|
|
1118
|
-
}
|
|
1119
|
-
// When Toolroll can't check at all, the approver also says they checked: never a one-click confirmation.
|
|
1120
|
-
if (store.stopQuiescenceFact(run.id)?.kind === "unknown" && body.get("checked") !== "yes") {
|
|
1121
|
-
return taskScreen(response, who, act.taskId, `Make sure nothing from build #${run.id} is running, then tick the box to confirm.`, 409);
|
|
1122
|
-
}
|
|
1123
|
-
const settled = store.settleRunWitnessesByApprover({ runId: run.id, by: who.name, why: "Confirmed in the console that nothing from this build is running." }, now);
|
|
1124
|
-
if (!settled.ok) {
|
|
1125
|
-
return taskScreen(response, who, act.taskId, settled.reason === "alive" || settled.reason === "still-running"
|
|
1126
|
-
? `Something from build #${run.id} may still be running, so it can't be confirmed stopped yet.` : `Build #${run.id} can't be confirmed stopped.`, 409);
|
|
1127
|
-
}
|
|
1128
|
-
bustBadge();
|
|
1129
|
-
const to = resultReturnTarget(body.get("return"), run.id);
|
|
1130
|
-
return redirect(response, body.get("return") ? to : back);
|
|
1131
|
-
}
|
|
1132
|
-
if (act !== null && act.verb === "merge") {
|
|
1133
|
-
const body = readForm(posted, CONSOLE_FORMS.merge);
|
|
1134
|
-
// Merging is a person's act behind their password, typed again: a browser session, an approver, the exact
|
|
1135
|
-
// result's pull request. The flow re-reads GitHub before merging and records who merged in the ledger.
|
|
1136
|
-
if (who.via !== "cookie" || who.role !== "approver")
|
|
1137
|
-
return refuse(response, who, 403, "Only an approver can merge.");
|
|
1138
|
-
if (store.isDemo())
|
|
1139
|
-
return taskScreen(response, who, act.taskId, "The demo merges nothing.", 403);
|
|
1140
|
-
const ref = store.lookupRef(act.taskId);
|
|
1141
|
-
if (ref === null || !visible(ref.repo))
|
|
1142
|
-
return refuse(response, who, 404, "no such task", "/tasks");
|
|
1143
|
-
const named = (body.get("run") ?? "").trim();
|
|
1144
|
-
const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
|
|
1145
|
-
const runTask = run === null ? null : store.externalIdFor(run.taskRef);
|
|
1146
|
-
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
1147
|
-
return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
|
|
1148
|
-
}
|
|
1149
|
-
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 });
|
|
1150
|
-
if (!merged.ok)
|
|
1151
|
-
return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
|
|
1152
|
-
return redirect(response, `${taskHref(act.taskId)}#merge`);
|
|
1153
|
-
}
|
|
1154
|
-
if (act !== null) {
|
|
1155
|
-
return taskMutation(response, who, act.taskId, act.verb, posted, now);
|
|
1156
|
-
}
|
|
1157
|
-
const runNote = /^\/r\/([0-9]{1,15})\/note$/.exec(url.pathname);
|
|
1158
|
-
if (runNote !== null) {
|
|
1159
|
-
const body = readForm(posted, CONSOLE_FORMS.runNote);
|
|
1160
|
-
// An operator's verdict beside the machine's record (M6): immutable,
|
|
1161
|
-
// bounded by the same validator as decision notes, ceiling-checked
|
|
1162
|
-
// like every run resource. Ordinary authenticated mutation — no
|
|
1163
|
-
// nonce, because nothing here approves anything.
|
|
1164
|
-
const id = Number(runNote[1]);
|
|
1165
|
-
const found = store.getRun(id);
|
|
1166
|
-
if (found === null || !visible(taskRepoOf(found.taskRef))) {
|
|
1167
|
-
return refuse(response, who, 404, "no such run");
|
|
1168
|
-
}
|
|
1169
|
-
const note = validateNote(body.get("note") ?? "");
|
|
1170
|
-
if (!note.ok)
|
|
1171
|
-
return refuse(response, who, 400, note.problem);
|
|
1172
|
-
store.addRunNote(id, who.name, note.note, now);
|
|
1173
|
-
return redirect(response, `/r/${id}`);
|
|
1086
|
+
if (answer === null)
|
|
1087
|
+
return noPage(who, response);
|
|
1088
|
+
const body = readForm(posted, CONSOLE_FORMS.decisionAnswer);
|
|
1089
|
+
const id = Number(answer[1]);
|
|
1090
|
+
const requestedReturn = body.get("return");
|
|
1091
|
+
const decisionBack = requestedReturn === "next"
|
|
1092
|
+
? "/next"
|
|
1093
|
+
: requestedReturn === null
|
|
1094
|
+
? `/d/${id}`
|
|
1095
|
+
: safeChatReturn(requestedReturn);
|
|
1096
|
+
const decision = store.getDecision(id);
|
|
1097
|
+
if (decision === null)
|
|
1098
|
+
return refuse(response, who, 404, "no such decision");
|
|
1099
|
+
const answeringRun = store.getRun(decision.run);
|
|
1100
|
+
if (answeringRun !== null && !visible(taskRepoOf(answeringRun.taskRef))) {
|
|
1101
|
+
return refuse(response, who, 404, "no such decision");
|
|
1174
1102
|
}
|
|
1103
|
+
const choice = body.get("choice") ?? "";
|
|
1104
|
+
const chosen = decision.options.find(option => option.id === choice);
|
|
1105
|
+
// Irreversible options never ride one accidental tap: the form arms
|
|
1106
|
+
// them behind an explicit confirmation field, and the server checks —
|
|
1107
|
+
// the client rendering is convenience, this is the rule.
|
|
1108
|
+
if (chosen !== undefined && !chosen.reversible && body.get("confirm") !== "yes") {
|
|
1109
|
+
return refuse(response, who, 400, "an irreversible choice must be confirmed", requestedReturn === null ? `/d/${id}` : `/d/${id}?return=${encodeURIComponent(decisionBack)}`);
|
|
1110
|
+
}
|
|
1111
|
+
const note = body.get("note");
|
|
1112
|
+
const answered = store.answerDecision({
|
|
1113
|
+
id,
|
|
1114
|
+
choice,
|
|
1115
|
+
by: who.name,
|
|
1116
|
+
via: "web",
|
|
1117
|
+
...(note === null || note === "" ? {} : { note }),
|
|
1118
|
+
}, now);
|
|
1119
|
+
if (!answered.ok) {
|
|
1120
|
+
const status = answered.reason === "bad-option" || answered.reason === "bad-note" ? 400 : 409;
|
|
1121
|
+
const why = answered.reason === "already-answered" ? "already answered — somebody got there first" : answered.reason;
|
|
1122
|
+
return refuse(response, who, status, why, requestedReturn === null ? `/d/${id}` : decisionBack);
|
|
1123
|
+
}
|
|
1124
|
+
return redirect(response, decisionBack);
|
|
1125
|
+
}
|
|
1126
|
+
async function taskConfirmStopped(ctx) {
|
|
1127
|
+
const { url, who, response, now, posted } = ctx;
|
|
1128
|
+
const act = matchTaskPath(url.pathname, "/confirm-stopped$");
|
|
1129
|
+
if (act === null)
|
|
1130
|
+
return noPage(who, response);
|
|
1131
|
+
const body = readForm(posted, CONSOLE_FORMS.confirmStopped);
|
|
1132
|
+
// Confirming a finished build stopped is an approver's act behind the password, typed again — the same
|
|
1133
|
+
// record as `toolroll run settle`, refused while anything of the run may still be running.
|
|
1134
|
+
const back = taskHref(act.taskId);
|
|
1135
|
+
const ref = store.lookupRef(act.taskId);
|
|
1136
|
+
if (ref === null || !visible(ref.repo))
|
|
1137
|
+
return refuse(response, who, 404, "no such task", "/tasks");
|
|
1138
|
+
const named = (body.get("run") ?? "").trim();
|
|
1139
|
+
const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
|
|
1140
|
+
const runTask = run === null ? null : store.externalIdFor(run.taskRef);
|
|
1141
|
+
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
1142
|
+
return taskScreen(response, who, act.taskId, "That build isn't part of this task.", 409);
|
|
1143
|
+
}
|
|
1144
|
+
if (!authenticateApprover(who, body.get("token") ?? "", ref.repo).ok || !store.accountCanAccess(who.name, ref.repo)) {
|
|
1145
|
+
return taskScreen(response, who, act.taskId, "That password didn't match. Nothing changed.", 403);
|
|
1146
|
+
}
|
|
1147
|
+
// When Toolroll can't check at all, the approver also says they checked: never a one-click confirmation.
|
|
1148
|
+
if (store.stopQuiescenceFact(run.id)?.kind === "unknown" && body.get("checked") !== "yes") {
|
|
1149
|
+
return taskScreen(response, who, act.taskId, `Make sure nothing from build #${run.id} is running, then tick the box to confirm.`, 409);
|
|
1150
|
+
}
|
|
1151
|
+
const settled = store.settleRunWitnessesByApprover({ runId: run.id, by: who.name, why: "Confirmed in the console that nothing from this build is running." }, now);
|
|
1152
|
+
if (!settled.ok) {
|
|
1153
|
+
return taskScreen(response, who, act.taskId, settled.reason === "alive" || settled.reason === "still-running"
|
|
1154
|
+
? `Something from build #${run.id} may still be running, so it can't be confirmed stopped yet.` : `Build #${run.id} can't be confirmed stopped.`, 409);
|
|
1155
|
+
}
|
|
1156
|
+
bustBadge();
|
|
1157
|
+
const to = resultReturnTarget(body.get("return"), run.id);
|
|
1158
|
+
return redirect(response, body.get("return") ? to : back);
|
|
1159
|
+
}
|
|
1160
|
+
async function taskMerge(ctx) {
|
|
1161
|
+
const { url, who, response, posted } = ctx;
|
|
1162
|
+
const act = matchTaskPath(url.pathname, "/merge$");
|
|
1163
|
+
if (act === null)
|
|
1164
|
+
return noPage(who, response);
|
|
1165
|
+
const body = readForm(posted, CONSOLE_FORMS.merge);
|
|
1166
|
+
// Merging is a person's act behind their password, typed again: a browser session, an approver, the exact
|
|
1167
|
+
// result's pull request. The flow re-reads GitHub before merging and records who merged in the ledger.
|
|
1168
|
+
if (store.isDemo())
|
|
1169
|
+
return taskScreen(response, who, act.taskId, "The demo merges nothing.", 403);
|
|
1170
|
+
const ref = store.lookupRef(act.taskId);
|
|
1171
|
+
if (ref === null || !visible(ref.repo))
|
|
1172
|
+
return refuse(response, who, 404, "no such task", "/tasks");
|
|
1173
|
+
const named = (body.get("run") ?? "").trim();
|
|
1174
|
+
const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
|
|
1175
|
+
const runTask = run === null ? null : store.externalIdFor(run.taskRef);
|
|
1176
|
+
if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
|
|
1177
|
+
return taskScreen(response, who, act.taskId, "That pull request isn't part of this task.", 409);
|
|
1178
|
+
}
|
|
1179
|
+
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 });
|
|
1180
|
+
if (!merged.ok)
|
|
1181
|
+
return taskScreen(response, who, act.taskId, merged.message, merged.reason === "password" ? 403 : 409);
|
|
1182
|
+
return redirect(response, `${taskHref(act.taskId)}#merge`);
|
|
1183
|
+
}
|
|
1184
|
+
/** A task act bound to its verb: taskMutation owns each verb's checks. */
|
|
1185
|
+
const taskAct = (verb) => async (ctx) => {
|
|
1186
|
+
const { url, who, response, now, posted } = ctx;
|
|
1187
|
+
const act = matchTaskPath(url.pathname, `/${verb}$`);
|
|
1188
|
+
if (act === null)
|
|
1189
|
+
return noPage(who, response);
|
|
1190
|
+
return taskMutation(response, who, act.taskId, verb, posted, now);
|
|
1191
|
+
};
|
|
1192
|
+
async function runNote(ctx) {
|
|
1193
|
+
const { url, who, response, now, posted } = ctx;
|
|
1194
|
+
const noted = /^\/r\/([0-9]{1,15})\/note$/.exec(url.pathname);
|
|
1195
|
+
if (noted === null)
|
|
1196
|
+
return noPage(who, response);
|
|
1197
|
+
const body = readForm(posted, CONSOLE_FORMS.runNote);
|
|
1198
|
+
// An operator's verdict beside the machine's record (M6): immutable,
|
|
1199
|
+
// bounded by the same validator as decision notes, ceiling-checked
|
|
1200
|
+
// like every run resource. Ordinary authenticated mutation — no
|
|
1201
|
+
// nonce, because nothing here approves anything.
|
|
1202
|
+
const id = Number(noted[1]);
|
|
1203
|
+
const found = store.getRun(id);
|
|
1204
|
+
if (found === null || !visible(taskRepoOf(found.taskRef))) {
|
|
1205
|
+
return refuse(response, who, 404, "no such run");
|
|
1206
|
+
}
|
|
1207
|
+
const note = validateNote(body.get("note") ?? "");
|
|
1208
|
+
if (!note.ok)
|
|
1209
|
+
return refuse(response, who, 400, note.problem);
|
|
1210
|
+
store.addRunNote(id, who.name, note.note, now);
|
|
1211
|
+
return redirect(response, `/r/${id}`);
|
|
1212
|
+
}
|
|
1213
|
+
async function runComment(ctx) {
|
|
1214
|
+
const { url, who, response, now, posted } = ctx;
|
|
1175
1215
|
const diffComment = /^\/r\/([0-9]{1,15})\/comment$/.exec(url.pathname);
|
|
1176
|
-
if (diffComment
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
// Where the reader came from (package 3): the cockpit's deep link,
|
|
1188
|
-
// the chat's result view, or the run page — validated to those exact
|
|
1189
|
-
// shapes. A refusal sends them back THERE, where the browser has kept
|
|
1190
|
-
// their draft, so a failed submission is recoverable in place.
|
|
1191
|
-
const returnTo = resultReturnTarget(body.get("return"), id);
|
|
1192
|
-
const selectedTab = parseResultTab(body.get("tab"));
|
|
1193
|
-
const back = selectedTab === "summary" ? returnTo : `${returnTo}${returnTo.includes("?") ? "&" : "?"}tab=${selectedTab}`;
|
|
1194
|
-
if (body.get("intent") === "revise") {
|
|
1195
|
-
const result = requestResultChanges(store, evidenceRoot, {
|
|
1196
|
-
run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
|
|
1197
|
-
repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
|
|
1198
|
-
note: body.get("note") ?? "", path: body.get("path") ?? "", line: body.get("line") ?? "", request: body.get("request"),
|
|
1199
|
-
}, now);
|
|
1200
|
-
if (!result.ok) {
|
|
1201
|
-
const request = body.get("request") ?? "";
|
|
1202
|
-
const retryBack = /^[a-f0-9]{32}$/.test(request) ? `${back}${back.includes("?") ? "&" : "?"}conflict=${request}#request-changes` : back;
|
|
1203
|
-
return refuse(response, who, result.status, result.message, retryBack);
|
|
1204
|
-
}
|
|
1205
|
-
return redirect(response, revisionDestination(result.id, returnTo));
|
|
1206
|
-
}
|
|
1207
|
-
const terminal = store.artifactsFor(id).find(one => one.kind === "terminal-diff");
|
|
1208
|
-
if (terminal === undefined) {
|
|
1209
|
-
return refuse(response, who, 400, "this run has no terminal diff to comment on", back);
|
|
1210
|
-
}
|
|
1211
|
-
// The bytes must VERIFY before words attach to them (audit IV-10):
|
|
1212
|
-
// "a comment on the exact reviewed bytes" is a lie if the bytes are
|
|
1213
|
-
// gone or no longer hash to their record.
|
|
1214
|
-
const proven = readVerifiedArtifact(evidenceRoot, terminal);
|
|
1215
|
-
if (!proven.ok) {
|
|
1216
|
-
return refuse(response, who, 409, `the terminal diff no longer verifies (${proven.problem}) — nothing to comment on`, back);
|
|
1217
|
-
}
|
|
1218
|
-
const note = validateNote(body.get("note") ?? "");
|
|
1219
|
-
if (!note.ok)
|
|
1220
|
-
return refuse(response, who, 400, note.problem, back);
|
|
1221
|
-
const rawPath = (body.get("path") ?? "").trim();
|
|
1222
|
-
if (rawPath.length > 300 || hasForbiddenControls(rawPath)) {
|
|
1223
|
-
return refuse(response, who, 400, "Enter a file path of up to 300 characters, without control characters.", back);
|
|
1224
|
-
}
|
|
1225
|
-
const rawLine = (body.get("line") ?? "").trim();
|
|
1226
|
-
const line = rawLine === "" ? null : Number(rawLine);
|
|
1227
|
-
if (line !== null && (!Number.isInteger(line) || line < 1 || line > 1_000_000)) {
|
|
1228
|
-
return refuse(response, who, 400, "Enter a whole line number from 1 to 1,000,000, or leave it blank.", back);
|
|
1229
|
-
}
|
|
1230
|
-
// The form's own request token (package 3) is the dedupe key: a
|
|
1231
|
-
// replayed or double submission finds its note already recorded and
|
|
1232
|
-
// lands on the same receipt instead of a second note. The token is
|
|
1233
|
-
// bound to the account, so nobody else's replay can consume it.
|
|
1234
|
-
const request = body.get("request");
|
|
1235
|
-
const sourceKey = commentSourceKey(who.name, request);
|
|
1236
|
-
const path = rawPath === "" ? null : rawPath;
|
|
1237
|
-
const inserted = store.addDiffComment({ artifactId: terminal.id, runId: id, path, line, note: note.note, author: who.name, ...(sourceKey === undefined ? {} : { sourceKey }) }, now);
|
|
1238
|
-
if (inserted === null && sourceKey !== undefined) {
|
|
1239
|
-
// The token was used before (repair 2026-09-14, finding 1). A
|
|
1240
|
-
// request identity is IMMUTABLE: it names exactly one note on
|
|
1241
|
-
// exactly one result. The unchanged retry — same run, same file,
|
|
1242
|
-
// line and words — lands on its original receipt; any other reuse
|
|
1243
|
-
// is refused, and the refusal's way back carries `conflict=<token>`
|
|
1244
|
-
// so the browser mints a fresh identity for the words it still
|
|
1245
|
-
// holds. Nothing typed is lost either way.
|
|
1246
|
-
const earlier = store.diffCommentBySourceKey(sourceKey);
|
|
1247
|
-
const conflictBack = `${back}${back.includes("?") ? "&" : "?"}conflict=${request}#request-changes`;
|
|
1248
|
-
if (earlier === null || earlier.run !== id) {
|
|
1249
|
-
return refuse(response, who, 409, `this note's request identity was already used on another result (build #${earlier === null ? "?" : earlier.run}) — go back; your words are kept, and the form will carry a new identity`, conflictBack);
|
|
1250
|
-
}
|
|
1251
|
-
if (earlier.path !== path || earlier.line !== line || earlier.note !== note.note) {
|
|
1252
|
-
return refuse(response, who, 409, `this request identity already recorded a different note on this result (${earlier.path === null ? "no file" : `${earlier.path}${earlier.line === null ? "" : `:${earlier.line}`}`}: "${oneLineOf(earlier.note, 80)}") — go back; your edited words are kept as a new note, and the form will carry a new identity`, conflictBack);
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
// Land back AT the request-changes form with the note field ready —
|
|
1256
|
-
// writing five notes in a row must cost five keystrokes of
|
|
1257
|
-
// navigation, not five scrolls (arc 6, finding 5/6). The receipt
|
|
1258
|
-
// names the request token, so the browser clears exactly the draft
|
|
1259
|
-
// that landed and no other.
|
|
1260
|
-
const receipt = sourceKey === undefined ? "1" : request;
|
|
1261
|
-
return redirect(response, `${back}${back.includes("?") ? "&" : "?"}noted=${receipt}#request-changes`);
|
|
1262
|
-
}
|
|
1263
|
-
if (url.pathname === "/session/editor-links") {
|
|
1264
|
-
const body = readForm(posted, CONSOLE_FORMS.editorLinks);
|
|
1265
|
-
// The session half of the editor-link activation (arc 6, finding 1):
|
|
1266
|
-
// only the person at the browser can say "this device holds the
|
|
1267
|
-
// worktrees". Per-session, dies with the session, grants nothing —
|
|
1268
|
-
// it only lets already-authorized pages RENDER vscode links.
|
|
1269
|
-
if (who.via !== "cookie")
|
|
1270
|
-
return refuse(response, who, 403, "editor links are a browser session's choice");
|
|
1271
|
-
if (options.editorLinks === undefined)
|
|
1272
|
-
return refuse(response, who, 404, "editor links are not enabled on this server");
|
|
1273
|
-
who.session.editorLinks = body.get("on") === "1";
|
|
1274
|
-
const back = body.get("return") ?? "/";
|
|
1275
|
-
return redirect(response, /^\/[a-z0-9/_-]*$/i.test(back) ? back : "/");
|
|
1216
|
+
if (diffComment === null)
|
|
1217
|
+
return noPage(who, response);
|
|
1218
|
+
const body = readForm(posted, CONSOLE_FORMS.diffComment);
|
|
1219
|
+
// A review comment on the IMMUTABLE terminal diff (M6.8): bound to
|
|
1220
|
+
// the exact artifact and its hash. Ordinary authenticated mutation —
|
|
1221
|
+
// the nonce belongs to the approval screen that later restates the
|
|
1222
|
+
// batch, never to the comment box.
|
|
1223
|
+
const id = Number(diffComment[1]);
|
|
1224
|
+
const found = store.getRun(id);
|
|
1225
|
+
if (found === null || !visible(taskRepoOf(found.taskRef))) {
|
|
1226
|
+
return refuse(response, who, 404, "no such run");
|
|
1276
1227
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
const
|
|
1286
|
-
if (found === null || !visible(taskRepoOf(found.taskRef))) {
|
|
1287
|
-
return refuse(response, who, 404, "no such run");
|
|
1288
|
-
}
|
|
1289
|
-
const back = resultReturnTarget(body.get("return"), id);
|
|
1290
|
-
const result = createResultRevision(store, evidenceRoot, {
|
|
1228
|
+
// Where the reader came from (package 3): the cockpit's deep link,
|
|
1229
|
+
// the chat's result view, or the run page — validated to those exact
|
|
1230
|
+
// shapes. A refusal sends them back THERE, where the browser has kept
|
|
1231
|
+
// their draft, so a failed submission is recoverable in place.
|
|
1232
|
+
const returnTo = resultReturnTarget(body.get("return"), id);
|
|
1233
|
+
const selectedTab = parseResultTab(body.get("tab"));
|
|
1234
|
+
const back = selectedTab === "summary" ? returnTo : `${returnTo}${returnTo.includes("?") ? "&" : "?"}tab=${selectedTab}`;
|
|
1235
|
+
if (body.get("intent") === "revise") {
|
|
1236
|
+
const result = requestResultChanges(store, evidenceRoot, {
|
|
1291
1237
|
run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
|
|
1292
1238
|
repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
|
|
1239
|
+
note: body.get("note") ?? "", path: body.get("path") ?? "", line: body.get("line") ?? "", request: body.get("request"),
|
|
1293
1240
|
}, now);
|
|
1294
|
-
if (!result.ok)
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
// Follow-ups on a result: Run checks on its exact commit (a worker runs the
|
|
1299
|
-
// approved command once and seals the log), or file a task to add tests.
|
|
1300
|
-
const followUp = /^\/r\/([0-9]{1,15})\/(checks|add-tests)$/.exec(url.pathname);
|
|
1301
|
-
if (followUp !== null) {
|
|
1302
|
-
const body = readForm(posted, CONSOLE_FORMS.followUp);
|
|
1303
|
-
const id = Number(followUp[1]);
|
|
1304
|
-
const found = store.getRun(id);
|
|
1305
|
-
if (found === null || !visible(taskRepoOf(found.taskRef)))
|
|
1306
|
-
return refuse(response, who, 404, "no such run");
|
|
1307
|
-
// The task page that offered Run checks in place is a return target too: this run's own task, or its family's root.
|
|
1308
|
-
const runTask = store.externalIdFor(found.taskRef);
|
|
1309
|
-
const back = resultReturnTarget(body.get("return"), id, runTask === null ? [] : [runTask, familyOf(runTask)?.root.id ?? runTask]);
|
|
1310
|
-
if (who.via !== "cookie")
|
|
1311
|
-
return refuse(response, who, 403, "Sign in with a browser session to do this.", back);
|
|
1312
|
-
if (followUp[2] === "checks") {
|
|
1313
|
-
if (who.role !== "approver")
|
|
1314
|
-
return refuse(response, who, 403, "An approver runs checks.", back);
|
|
1315
|
-
if (store.isDemo())
|
|
1316
|
-
return refuse(response, who, 403, "The demo runs no checks.", back);
|
|
1317
|
-
const level = body.get("level") === "full" ? "full" : "quick";
|
|
1318
|
-
const asked = requestFollowUpChecks(store, { runId: id, level, actor: who.name }, now);
|
|
1319
|
-
if (!asked.ok)
|
|
1320
|
-
return refuse(response, who, 409, asked.message, back);
|
|
1321
|
-
// Back where it was asked: the task page's status row says the checks are running; a result page shows them under #follow-ups.
|
|
1322
|
-
return redirect(response, back.startsWith("/t/") ? back : `${back.split("#")[0]}#follow-ups`);
|
|
1241
|
+
if (!result.ok) {
|
|
1242
|
+
const request = body.get("request") ?? "";
|
|
1243
|
+
const retryBack = /^[a-f0-9]{32}$/.test(request) ? `${back}${back.includes("?") ? "&" : "?"}conflict=${request}#request-changes` : back;
|
|
1244
|
+
return refuse(response, who, result.status, result.message, retryBack);
|
|
1323
1245
|
}
|
|
1324
|
-
|
|
1325
|
-
if (!filed.ok)
|
|
1326
|
-
return refuse(response, who, 409, filed.message, back);
|
|
1327
|
-
return redirect(response, taskHref(filed.id));
|
|
1246
|
+
return redirect(response, revisionDestination(result.id, returnTo));
|
|
1328
1247
|
}
|
|
1329
|
-
const
|
|
1330
|
-
if (
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
// the source rows inside the seal; the scope read above only names
|
|
1376
|
-
// the digest this draft was composed against.
|
|
1377
|
-
const sealed = store.sealRevision({
|
|
1378
|
-
source: { task: sourceTaskId, run: id, scopeDigest: sourceScope?.digest ?? null },
|
|
1379
|
-
brief: { evidenceRoot, key, sha256: createHash("sha256").update(briefBytes).digest("hex"), bytes: briefBytes.length, capture: "machine-authored ci-repair brief (exit 0)" },
|
|
1380
|
-
child: {
|
|
1381
|
-
id: draftId,
|
|
1382
|
-
title: `repair ${sourceTaskId}: CI failing on PR #${publication.prNumber}`,
|
|
1383
|
-
repair: `repair the failing CI on PR #${publication.prNumber} (failing head ${(episode?.headSha ?? publication.headSha).slice(0, 12)}). ` +
|
|
1384
|
-
`Read the failing checks on GitHub before approving; this draft carries no log content.`,
|
|
1385
|
-
},
|
|
1386
|
-
commentIds: null,
|
|
1387
|
-
requestedBy: who.name,
|
|
1388
|
-
}, now);
|
|
1389
|
-
if (!sealed.ok) {
|
|
1390
|
-
return refuse(response, who, sealed.reason === "duplicate" ? 409 : sealed.reason === "stale-source" || sealed.reason === "comments-taken" ? 409 : 400, sealed.reason === "duplicate" ? `already drafted as ${draftId}` : `could not draft: ${sealed.detail}`, `/r/${id}`);
|
|
1248
|
+
const terminal = store.artifactsFor(id).find(one => one.kind === "terminal-diff");
|
|
1249
|
+
if (terminal === undefined) {
|
|
1250
|
+
return refuse(response, who, 400, "this run has no terminal diff to comment on", back);
|
|
1251
|
+
}
|
|
1252
|
+
// The bytes must VERIFY before words attach to them (audit IV-10):
|
|
1253
|
+
// "a comment on the exact reviewed bytes" is a lie if the bytes are
|
|
1254
|
+
// gone or no longer hash to their record.
|
|
1255
|
+
const proven = readVerifiedArtifact(evidenceRoot, terminal);
|
|
1256
|
+
if (!proven.ok) {
|
|
1257
|
+
return refuse(response, who, 409, `the terminal diff no longer verifies (${proven.problem}) — nothing to comment on`, back);
|
|
1258
|
+
}
|
|
1259
|
+
const note = validateNote(body.get("note") ?? "");
|
|
1260
|
+
if (!note.ok)
|
|
1261
|
+
return refuse(response, who, 400, note.problem, back);
|
|
1262
|
+
const rawPath = (body.get("path") ?? "").trim();
|
|
1263
|
+
if (rawPath.length > 300 || hasForbiddenControls(rawPath)) {
|
|
1264
|
+
return refuse(response, who, 400, "Enter a file path of up to 300 characters, without control characters.", back);
|
|
1265
|
+
}
|
|
1266
|
+
const rawLine = (body.get("line") ?? "").trim();
|
|
1267
|
+
const line = rawLine === "" ? null : Number(rawLine);
|
|
1268
|
+
if (line !== null && (!Number.isInteger(line) || line < 1 || line > 1_000_000)) {
|
|
1269
|
+
return refuse(response, who, 400, "Enter a whole line number from 1 to 1,000,000, or leave it blank.", back);
|
|
1270
|
+
}
|
|
1271
|
+
// The form's own request token (package 3) is the dedupe key: a
|
|
1272
|
+
// replayed or double submission finds its note already recorded and
|
|
1273
|
+
// lands on the same receipt instead of a second note. The token is
|
|
1274
|
+
// bound to the account, so nobody else's replay can consume it.
|
|
1275
|
+
const request = body.get("request");
|
|
1276
|
+
const sourceKey = commentSourceKey(who.name, request);
|
|
1277
|
+
const path = rawPath === "" ? null : rawPath;
|
|
1278
|
+
const inserted = store.addDiffComment({ artifactId: terminal.id, runId: id, path, line, note: note.note, author: who.name, ...(sourceKey === undefined ? {} : { sourceKey }) }, now);
|
|
1279
|
+
if (inserted === null && sourceKey !== undefined) {
|
|
1280
|
+
// The token was used before (repair 2026-09-14, finding 1). A
|
|
1281
|
+
// request identity is IMMUTABLE: it names exactly one note on
|
|
1282
|
+
// exactly one result. The unchanged retry — same run, same file,
|
|
1283
|
+
// line and words — lands on its original receipt; any other reuse
|
|
1284
|
+
// is refused, and the refusal's way back carries `conflict=<token>`
|
|
1285
|
+
// so the browser mints a fresh identity for the words it still
|
|
1286
|
+
// holds. Nothing typed is lost either way.
|
|
1287
|
+
const earlier = store.diffCommentBySourceKey(sourceKey);
|
|
1288
|
+
const conflictBack = `${back}${back.includes("?") ? "&" : "?"}conflict=${request}#request-changes`;
|
|
1289
|
+
if (earlier === null || earlier.run !== id) {
|
|
1290
|
+
return refuse(response, who, 409, `this note's request identity was already used on another result (build #${earlier === null ? "?" : earlier.run}) — go back; your words are kept, and the form will carry a new identity`, conflictBack);
|
|
1291
|
+
}
|
|
1292
|
+
if (earlier.path !== path || earlier.line !== line || earlier.note !== note.note) {
|
|
1293
|
+
return refuse(response, who, 409, `this request identity already recorded a different note on this result (${earlier.path === null ? "no file" : `${earlier.path}${earlier.line === null ? "" : `:${earlier.line}`}`}: "${oneLineOf(earlier.note, 80)}") — go back; your edited words are kept as a new note, and the form will carry a new identity`, conflictBack);
|
|
1391
1294
|
}
|
|
1392
|
-
// The merge blocker rides the SAME breath as the draft (merge grant,
|
|
1393
|
-
// findings 3/12/13): while this repair exists, the source PR merges
|
|
1394
|
-
// NOTHING — sticky until the operator's unblock act or the PR closes.
|
|
1395
|
-
store.createMergeBlocker(publication.id, sealed.id, now);
|
|
1396
|
-
return redirect(response, taskHref(sealed.id));
|
|
1397
1295
|
}
|
|
1296
|
+
// Land back AT the request-changes form with the note field ready —
|
|
1297
|
+
// writing five notes in a row must cost five keystrokes of
|
|
1298
|
+
// navigation, not five scrolls (arc 6, finding 5/6). The receipt
|
|
1299
|
+
// names the request token, so the browser clears exactly the draft
|
|
1300
|
+
// that landed and no other.
|
|
1301
|
+
const receipt = sourceKey === undefined ? "1" : request;
|
|
1302
|
+
return redirect(response, `${back}${back.includes("?") ? "&" : "?"}noted=${receipt}#request-changes`);
|
|
1303
|
+
}
|
|
1304
|
+
async function sessionEditorLinks(ctx) {
|
|
1305
|
+
const { who, response, posted } = ctx;
|
|
1306
|
+
const body = readForm(posted, CONSOLE_FORMS.editorLinks);
|
|
1307
|
+
// The session half of the editor-link activation (arc 6, finding 1):
|
|
1308
|
+
// only the person at the browser can say "this device holds the
|
|
1309
|
+
// worktrees". Per-session, dies with the session, grants nothing —
|
|
1310
|
+
// it only lets already-authorized pages RENDER vscode links.
|
|
1311
|
+
if (who.via !== "cookie")
|
|
1312
|
+
return refuse(response, who, 403, "editor links are a browser session's choice");
|
|
1313
|
+
if (options.editorLinks === undefined)
|
|
1314
|
+
return refuse(response, who, 404, "editor links are not enabled on this server");
|
|
1315
|
+
who.session.editorLinks = body.get("on") === "1";
|
|
1316
|
+
const back = body.get("return") ?? "/";
|
|
1317
|
+
return redirect(response, /^\/[a-z0-9/_-]*$/i.test(back) ? back : "/");
|
|
1318
|
+
}
|
|
1319
|
+
async function runRevise(ctx) {
|
|
1320
|
+
const { url, who, response, now, posted } = ctx;
|
|
1321
|
+
const revise = /^\/r\/([0-9]{1,15})\/revise$/.exec(url.pathname);
|
|
1322
|
+
if (revise === null)
|
|
1323
|
+
return noPage(who, response);
|
|
1324
|
+
const body = readForm(posted, CONSOLE_FORMS.revise);
|
|
1325
|
+
// Seal the live comment batch into ONE unapproved revision task with
|
|
1326
|
+
// an immutable brief (M6.8). Deterministic — no model reads anything
|
|
1327
|
+
// here — and every revision takes its own approval: comments can
|
|
1328
|
+
// semantically widen work, and no path check can prove they did not.
|
|
1329
|
+
const id = Number(revise[1]);
|
|
1330
|
+
const found = store.getRun(id);
|
|
1331
|
+
if (found === null || !visible(taskRepoOf(found.taskRef))) {
|
|
1332
|
+
return refuse(response, who, 404, "no such run");
|
|
1333
|
+
}
|
|
1334
|
+
const back = resultReturnTarget(body.get("return"), id);
|
|
1335
|
+
const result = createResultRevision(store, evidenceRoot, {
|
|
1336
|
+
run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
|
|
1337
|
+
repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
|
|
1338
|
+
}, now);
|
|
1339
|
+
if (!result.ok)
|
|
1340
|
+
return refuse(response, who, result.status, result.message, back);
|
|
1341
|
+
return redirect(response, revisionDestination(result.id, back));
|
|
1342
|
+
}
|
|
1343
|
+
// Follow-ups on a result: Run checks on its exact commit (a worker runs the
|
|
1344
|
+
// approved command once and seals the log), or file a task to add tests.
|
|
1345
|
+
const runFollowUp = (op) => async (ctx) => {
|
|
1346
|
+
const { url, who, response, now, posted } = ctx;
|
|
1347
|
+
const followUp = /^\/r\/([0-9]{1,15})\//.exec(url.pathname);
|
|
1348
|
+
if (followUp === null)
|
|
1349
|
+
return noPage(who, response);
|
|
1350
|
+
const body = readForm(posted, CONSOLE_FORMS.followUp);
|
|
1351
|
+
const id = Number(followUp[1]);
|
|
1352
|
+
const found = store.getRun(id);
|
|
1353
|
+
if (found === null || !visible(taskRepoOf(found.taskRef)))
|
|
1354
|
+
return refuse(response, who, 404, "no such run");
|
|
1355
|
+
// The task page that offered Run checks in place is a return target too: this run's own task, or its family's root.
|
|
1356
|
+
const runTask = store.externalIdFor(found.taskRef);
|
|
1357
|
+
const back = resultReturnTarget(body.get("return"), id, runTask === null ? [] : [runTask, familyOf(runTask)?.root.id ?? runTask]);
|
|
1358
|
+
if (who.via !== "cookie")
|
|
1359
|
+
return refuse(response, who, 403, "Sign in with a browser session to do this.", back);
|
|
1360
|
+
if (op === "checks") {
|
|
1361
|
+
if (who.role !== "approver")
|
|
1362
|
+
return refuse(response, who, 403, "An approver runs checks.", back);
|
|
1363
|
+
if (store.isDemo())
|
|
1364
|
+
return refuse(response, who, 403, "The demo runs no checks.", back);
|
|
1365
|
+
const level = body.get("level") === "full" ? "full" : "quick";
|
|
1366
|
+
const asked = requestFollowUpChecks(store, { runId: id, level, actor: who.name }, now);
|
|
1367
|
+
if (!asked.ok)
|
|
1368
|
+
return refuse(response, who, 409, asked.message, back);
|
|
1369
|
+
// Back where it was asked: the task page's status row says the checks are running; a result page shows them under #follow-ups.
|
|
1370
|
+
return redirect(response, back.startsWith("/t/") ? back : `${back.split("#")[0]}#follow-ups`);
|
|
1371
|
+
}
|
|
1372
|
+
const filed = fileAddTestsTask(store, evidenceRoot, { runId: id, actor: who.name, filedVia: "console", ...(admissionList() === null ? {} : { admittedRepos: admissionList() }) }, now);
|
|
1373
|
+
if (!filed.ok)
|
|
1374
|
+
return refuse(response, who, 409, filed.message, back);
|
|
1375
|
+
return redirect(response, taskHref(filed.id));
|
|
1376
|
+
};
|
|
1377
|
+
async function runDraftRepair(ctx) {
|
|
1378
|
+
const { url, who, response, now } = ctx;
|
|
1379
|
+
const draftRepair = /^\/r\/([0-9]{1,15})\/draft-repair$/.exec(url.pathname);
|
|
1380
|
+
if (draftRepair === null)
|
|
1381
|
+
return noPage(who, response);
|
|
1382
|
+
// CI repair, suggestion-first (M8.18): a red episode never spawns an
|
|
1383
|
+
// agent by itself — it EARNS a button, and the button creates one
|
|
1384
|
+
// unapproved task through the same revision machinery as review
|
|
1385
|
+
// comments. Deterministic id = one draft per task/PR, ever.
|
|
1386
|
+
const id = Number(draftRepair[1]);
|
|
1387
|
+
const found = store.getRun(id);
|
|
1388
|
+
if (found === null || !visible(taskRepoOf(found.taskRef))) {
|
|
1389
|
+
return refuse(response, who, 404, "no such run");
|
|
1390
|
+
}
|
|
1391
|
+
const publication = store.publicationForRun(id);
|
|
1392
|
+
if (publication === null || publication.prNumber === null) {
|
|
1393
|
+
return refuse(response, who, 400, "this run published no pull request", `/r/${id}`);
|
|
1394
|
+
}
|
|
1395
|
+
if (!store.hasOpenCiEpisode(publication.githubRepo, publication.prNumber)) {
|
|
1396
|
+
return refuse(response, who, 400, "no failing CI is observed on this PR right now", `/r/${id}`);
|
|
1397
|
+
}
|
|
1398
|
+
const sourceTaskId = store.externalIdFor(found.taskRef) ?? "?";
|
|
1399
|
+
const sourceScope = store.getScope(sourceTaskId);
|
|
1400
|
+
// The observed episode, not the click: the brief binds the head the
|
|
1401
|
+
// failure was SEEN on and when (audit C-2) — a PR that advanced since
|
|
1402
|
+
// is a different failure, and the click time is not an observation.
|
|
1403
|
+
const episode = store.latestOpenCiEpisode(publication.githubRepo, publication.prNumber);
|
|
1404
|
+
// Suffixes survive truncation (audit C-7): the prefix gives way, the
|
|
1405
|
+
// identity-bearing tail never does.
|
|
1406
|
+
const suffix = `-ci-${publication.prNumber}`;
|
|
1407
|
+
const draftId = `${sourceTaskId.slice(0, 64 - suffix.length)}${suffix}`;
|
|
1408
|
+
const brief = {
|
|
1409
|
+
schema: 1,
|
|
1410
|
+
kind: "ci-repair",
|
|
1411
|
+
sourceTask: sourceTaskId,
|
|
1412
|
+
sourceRun: id,
|
|
1413
|
+
sourceScopeDigest: sourceScope?.digest ?? null,
|
|
1414
|
+
head: found.headRevision,
|
|
1415
|
+
pr: publication.prNumber,
|
|
1416
|
+
prUrl: publication.prUrl,
|
|
1417
|
+
publishedHeadSha: publication.headSha,
|
|
1418
|
+
observedFailingHead: episode?.headSha ?? null,
|
|
1419
|
+
observedAt: episode?.createdAt ?? null,
|
|
1420
|
+
};
|
|
1421
|
+
const briefBytes = Buffer.from(JSON.stringify(brief, null, 2), "utf8");
|
|
1422
|
+
const briefName = `ci-repair-brief-${randomBytes(6).toString("hex")}.json`;
|
|
1423
|
+
const key = writeEvidenceFile(evidenceRoot, id, briefName, briefBytes);
|
|
1424
|
+
// The SAME revision boundary the annotation road and the criterion
|
|
1425
|
+
// repair use (contract handoff task 2): the inherited terms come from
|
|
1426
|
+
// the source rows inside the seal; the scope read above only names
|
|
1427
|
+
// the digest this draft was composed against.
|
|
1428
|
+
const sealed = store.sealRevision({
|
|
1429
|
+
source: { task: sourceTaskId, run: id, scopeDigest: sourceScope?.digest ?? null },
|
|
1430
|
+
brief: { evidenceRoot, key, sha256: createHash("sha256").update(briefBytes).digest("hex"), bytes: briefBytes.length, capture: "machine-authored ci-repair brief (exit 0)" },
|
|
1431
|
+
child: {
|
|
1432
|
+
id: draftId,
|
|
1433
|
+
title: `repair ${sourceTaskId}: CI failing on PR #${publication.prNumber}`,
|
|
1434
|
+
repair: `repair the failing CI on PR #${publication.prNumber} (failing head ${(episode?.headSha ?? publication.headSha).slice(0, 12)}). ` +
|
|
1435
|
+
`Read the failing checks on GitHub before approving; this draft carries no log content.`,
|
|
1436
|
+
},
|
|
1437
|
+
commentIds: null,
|
|
1438
|
+
requestedBy: who.name,
|
|
1439
|
+
}, now);
|
|
1440
|
+
if (!sealed.ok) {
|
|
1441
|
+
return refuse(response, who, sealed.reason === "duplicate" ? 409 : sealed.reason === "stale-source" || sealed.reason === "comments-taken" ? 409 : 400, sealed.reason === "duplicate" ? `already drafted as ${draftId}` : `could not draft: ${sealed.detail}`, `/r/${id}`);
|
|
1442
|
+
}
|
|
1443
|
+
// The merge blocker rides the SAME breath as the draft (merge grant,
|
|
1444
|
+
// findings 3/12/13): while this repair exists, the source PR merges
|
|
1445
|
+
// NOTHING — sticky until the operator's unblock act or the PR closes.
|
|
1446
|
+
store.createMergeBlocker(publication.id, sealed.id, now);
|
|
1447
|
+
return redirect(response, taskHref(sealed.id));
|
|
1448
|
+
}
|
|
1449
|
+
async function resolveIncident(ctx) {
|
|
1450
|
+
const { url, who, response, now, posted } = ctx;
|
|
1398
1451
|
const resolve = /^\/i\/([0-9]{1,15})\/resolve$/.exec(url.pathname);
|
|
1399
|
-
if (resolve
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
const resolved = store.resolveIncident(id, who.name, now);
|
|
1413
|
-
if (!resolved)
|
|
1414
|
-
return refuse(response, who, 409, "already resolved, or never open");
|
|
1415
|
-
const back = body.get("return") === "inbox" ? "/inbox" : openRow === undefined ? "/" : taskHref(openRow.taskId);
|
|
1416
|
-
return redirect(response, back);
|
|
1452
|
+
if (resolve === null)
|
|
1453
|
+
return noPage(who, response);
|
|
1454
|
+
const body = readForm(posted, CONSOLE_FORMS.resolveIncident);
|
|
1455
|
+
const id = Number(resolve[1]);
|
|
1456
|
+
// The ceiling applies to incident mutation exactly as to every other
|
|
1457
|
+
// resource (v3 review, finding 3): resolve incident → run → task, and
|
|
1458
|
+
// an incident outside this server's scope does not exist here. The
|
|
1459
|
+
// task id is captured BEFORE resolving — afterwards the incident is
|
|
1460
|
+
// no longer open and could not be found again.
|
|
1461
|
+
const openRow = store.openIncidents().find(one => one.id === id);
|
|
1462
|
+
const incidentRun = openRow === undefined ? null : store.getRun(openRow.run);
|
|
1463
|
+
if (openRow !== undefined && incidentRun !== null && !visible(taskRepoOf(incidentRun.taskRef))) {
|
|
1464
|
+
return refuse(response, who, 404, "no such incident");
|
|
1417
1465
|
}
|
|
1418
|
-
|
|
1466
|
+
const resolved = store.resolveIncident(id, who.name, now);
|
|
1467
|
+
if (!resolved)
|
|
1468
|
+
return refuse(response, who, 409, "already resolved, or never open");
|
|
1469
|
+
const back = body.get("return") === "inbox" ? "/inbox" : openRow === undefined ? "/" : taskHref(openRow.taskId);
|
|
1470
|
+
return redirect(response, back);
|
|
1419
1471
|
}
|
|
1420
1472
|
/** The compact recent-runs list for the master pane. */
|
|
1421
1473
|
function runListPane(project, currentId) {
|
|
1422
1474
|
const rows = store.listRunsBefore(null, 50, project);
|
|
1423
1475
|
const live = liveRunIds(rows);
|
|
1424
|
-
const items = rows
|
|
1425
|
-
.map(run => `<a class="item${run.id === currentId ? " current" : ""}" href="/r/${run.id}">`
|
|
1426
|
-
|
|
1427
|
-
`<span class="m">${runOutcomeBadge(run, live.has(run.id))}` +
|
|
1428
|
-
`<span class="mono">${whenTime(run.startedAt)}</span></span></a>`)
|
|
1429
|
-
.join("\n");
|
|
1430
|
-
return `<h2>Builds</h2>\n${items === "" ? `<p class="meta">None yet</p>` : items}`;
|
|
1476
|
+
const items = joinHtml(rows
|
|
1477
|
+
.map(run => html `<a class="item${run.id === currentId ? " current" : ""}" href="/r/${run.id}"><span class="t">#${run.id} \u00b7 ${run.taskId}</span><span class="m">${runOutcomeBadge(run, live.has(run.id))}<span class="mono">${whenTime(run.startedAt)}</span></span></a>`), "\n");
|
|
1478
|
+
return html `<h2>Builds</h2>\n${rows.length === 0 ? html `<p class="meta">None yet</p>` : items}`;
|
|
1431
1479
|
}
|
|
1432
1480
|
async function peekFragment(runId, sessionKey, editorMode = false) {
|
|
1481
|
+
// The cache and the in-flight coalescer hold the fragment's bytes (runtime's types); they are markup this made.
|
|
1482
|
+
const said = (message, final) => peekSay(message, final);
|
|
1433
1483
|
const guarded = peekGuards(runId);
|
|
1434
1484
|
if (!guarded.ok)
|
|
1435
|
-
return { status: 200, body:
|
|
1485
|
+
return { status: 200, body: said(guarded.message, guarded.final === true) };
|
|
1436
1486
|
const { run, worktree, epoch, entries } = guarded.admit;
|
|
1437
1487
|
// The cache and the in-flight coalescer both vary by LINK MODE (arc 6,
|
|
1438
1488
|
// finding 3): a linked fragment rendered for one session must never be
|
|
@@ -1444,17 +1494,17 @@ export function createTasksHandlers(runtime) {
|
|
|
1444
1494
|
}
|
|
1445
1495
|
if (cached !== undefined) {
|
|
1446
1496
|
peekCache.delete(key);
|
|
1447
|
-
runtime.peekCacheBytes -=
|
|
1497
|
+
runtime.peekCacheBytes -= cached.bytes;
|
|
1448
1498
|
}
|
|
1449
1499
|
// Coalesce per run; bound per session and globally (finding 10).
|
|
1450
1500
|
const flightKey = `${runId}:${editorMode ? "links" : "plain"}`;
|
|
1451
1501
|
const inFlight = peekInFlight.get(flightKey);
|
|
1452
1502
|
if (inFlight !== undefined)
|
|
1453
|
-
return { status: 200, body: await inFlight };
|
|
1503
|
+
return { status: 200, body: (await inFlight) };
|
|
1454
1504
|
if (peekInFlight.size >= PEEK_GLOBAL_INFLIGHT)
|
|
1455
|
-
return { status: 429, body:
|
|
1505
|
+
return { status: 429, body: said("the live view is busy — it retries by itself"), retryAfter: 10 };
|
|
1456
1506
|
if ((peekBySession.get(sessionKey) ?? 0) >= PEEK_SESSION_INFLIGHT) {
|
|
1457
|
-
return { status: 429, body:
|
|
1507
|
+
return { status: 429, body: said("too many live views from this session"), retryAfter: 10 };
|
|
1458
1508
|
}
|
|
1459
1509
|
peekBySession.set(sessionKey, (peekBySession.get(sessionKey) ?? 0) + 1);
|
|
1460
1510
|
const work = (async () => {
|
|
@@ -1470,64 +1520,64 @@ export function createTasksHandlers(runtime) {
|
|
|
1470
1520
|
return peekSay(seen.reason);
|
|
1471
1521
|
const stamp = clock().toISOString().slice(11, 19);
|
|
1472
1522
|
const parts = [
|
|
1473
|
-
`<p class="meta">Best-effort look at ${
|
|
1523
|
+
html `<p class="meta">Best-effort look at ${stamp} UTC — files can change mid-read</p>`,
|
|
1474
1524
|
];
|
|
1475
1525
|
const changed = seen.rows.filter(one => one.kind === "changed");
|
|
1476
1526
|
const deleted = seen.rows.filter(one => one.kind === "deleted");
|
|
1477
1527
|
const unchecked = seen.rows.filter(one => one.kind === "unchecked");
|
|
1478
1528
|
const fresh = aggregateNewNames(seen.newPaths);
|
|
1479
1529
|
if (changed.length === 0 && deleted.length === 0 && fresh.total === 0) {
|
|
1480
|
-
parts.push(`<p class="row">nothing has changed against the starting point yet</p>`);
|
|
1530
|
+
parts.push(html `<p class="row">nothing has changed against the starting point yet</p>`);
|
|
1481
1531
|
}
|
|
1482
1532
|
// A name is linked ONLY when sanitize provably changed nothing (arc 6,
|
|
1483
1533
|
// finding 3): a masked or normalized label must never carry an href
|
|
1484
1534
|
// that discloses what the mask hid. Collapsed labels never link.
|
|
1535
|
+
const shownName = (path) => peekName(path);
|
|
1485
1536
|
const linkedName = (path) => {
|
|
1486
|
-
const shown =
|
|
1487
|
-
if (!editorMode || shown !==
|
|
1537
|
+
const shown = shownName(path);
|
|
1538
|
+
if (!editorMode || htmlString(shown) !== htmlString(textHtml(path)))
|
|
1488
1539
|
return shown;
|
|
1489
1540
|
const href = editorFileHref(worktree, path);
|
|
1490
|
-
return href === null ? shown : `<a href="${
|
|
1541
|
+
return href === null ? shown : html `<a href="${href}">${shown}</a>`;
|
|
1491
1542
|
};
|
|
1492
|
-
const line = (row, mark) => `<p class="row mono">${mark} ${linkedName(row.path)} <span class="meta">${
|
|
1543
|
+
const line = (row, mark) => html `<p class="row mono">${mark} ${linkedName(row.path)} <span class="meta">${row.detail}</span></p>`;
|
|
1493
1544
|
for (const row of changed)
|
|
1494
1545
|
parts.push(line(row, "~"));
|
|
1495
1546
|
for (const row of deleted)
|
|
1496
1547
|
parts.push(line(row, "−"));
|
|
1497
1548
|
if (fresh.total > 0) {
|
|
1498
|
-
parts.push(`<p class="meta">New files · ${fresh.total}</p>`);
|
|
1549
|
+
parts.push(html `<p class="meta">New files · ${fresh.total}</p>`);
|
|
1499
1550
|
for (const row of fresh.rows) {
|
|
1500
1551
|
parts.push(row.collapsed
|
|
1501
|
-
? `<p class="row mono">+ ${
|
|
1502
|
-
: `<p class="row mono">+ ${
|
|
1552
|
+
? html `<p class="row mono">+ ${shownName(row.label)} <span class="meta">collapsed names — ${row.count} files</span></p>`
|
|
1553
|
+
: html `<p class="row mono">+ ${shownName(row.label)}</p>`);
|
|
1503
1554
|
}
|
|
1504
1555
|
if (fresh.renderedFiles < fresh.total) {
|
|
1505
|
-
parts.push(`<p class="meta">…and ${fresh.total - fresh.renderedFiles} more (${fresh.total} new files total)</p>`);
|
|
1556
|
+
parts.push(html `<p class="meta">…and ${fresh.total - fresh.renderedFiles} more (${fresh.total} new files total)</p>`);
|
|
1506
1557
|
}
|
|
1507
1558
|
}
|
|
1508
1559
|
if (unchecked.length > 0) {
|
|
1509
|
-
parts.push(`<p class="meta">Not verified this look — absence above does not mean unchanged:</p>`);
|
|
1560
|
+
parts.push(html `<p class="meta">Not verified this look — absence above does not mean unchanged:</p>`);
|
|
1510
1561
|
for (const row of unchecked)
|
|
1511
1562
|
parts.push(line(row, "?"));
|
|
1512
1563
|
}
|
|
1513
1564
|
if (seen.partial !== null)
|
|
1514
|
-
parts.push(`<p class="meta">${
|
|
1515
|
-
let fragment = parts
|
|
1516
|
-
if (Buffer.byteLength(fragment) > PEEK_FRAGMENT_BYTES) {
|
|
1565
|
+
parts.push(html `<p class="meta">${seen.partial}</p>`);
|
|
1566
|
+
let fragment = joinHtml(parts, "\n");
|
|
1567
|
+
if (Buffer.byteLength(htmlString(fragment)) > PEEK_FRAGMENT_BYTES) {
|
|
1517
1568
|
// The byte cap is enforced AFTER escaping (finding 32): an oversize
|
|
1518
1569
|
// rendering is replaced whole by its exact counts.
|
|
1519
|
-
fragment =
|
|
1520
|
-
`<p class="meta">Best-effort look at ${escape(stamp)} UTC</p>` +
|
|
1521
|
-
`<p class="row">${changed.length} changed · ${deleted.length} deleted · ${fresh.total} new · ${unchecked.length} unverified — too much to render live; the final diff will hold the detail</p>`;
|
|
1570
|
+
fragment = html `<p class="meta">Best-effort look at ${stamp} UTC</p><p class="row">${changed.length} changed · ${deleted.length} deleted · ${fresh.total} new · ${unchecked.length} unverified — too much to render live; the final diff will hold the detail</p>`;
|
|
1522
1571
|
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1572
|
+
const bytes = Buffer.byteLength(htmlString(fragment));
|
|
1573
|
+
peekCache.set(key, { fragment, bytes, at: Date.now() });
|
|
1574
|
+
runtime.peekCacheBytes += bytes;
|
|
1525
1575
|
peekEvict();
|
|
1526
1576
|
return fragment;
|
|
1527
1577
|
})();
|
|
1528
1578
|
peekInFlight.set(flightKey, work);
|
|
1529
1579
|
try {
|
|
1530
|
-
return { status: 200, body: await work };
|
|
1580
|
+
return { status: 200, body: (await work) };
|
|
1531
1581
|
}
|
|
1532
1582
|
finally {
|
|
1533
1583
|
peekInFlight.delete(flightKey);
|
|
@@ -2177,9 +2227,8 @@ export function createTasksHandlers(runtime) {
|
|
|
2177
2227
|
}
|
|
2178
2228
|
case "complete": {
|
|
2179
2229
|
const body = readForm(posted, CONSOLE_FORMS.taskComplete);
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
const principal = matePrincipal(who);
|
|
2230
|
+
// The row admits only an approver's browser session; the principal re-proves that session's standing.
|
|
2231
|
+
const principal = who.via === "cookie" ? leadPrincipal(who) : null;
|
|
2183
2232
|
if (principal === null)
|
|
2184
2233
|
return refuse(response, who, 403, "Your access changed. Sign in again.");
|
|
2185
2234
|
const digest = body.get("receipt") ?? "";
|
|
@@ -2241,11 +2290,11 @@ export function createTasksHandlers(runtime) {
|
|
|
2241
2290
|
const asked = requestTaskStop(store, { taskId, runId: Number(named), by: verifiedAuthor(who.name), via: "web" }, now);
|
|
2242
2291
|
if (!asked.ok) {
|
|
2243
2292
|
const words = {
|
|
2244
|
-
"no-run": `there is no run #${
|
|
2245
|
-
"wrong-task": `run #${
|
|
2246
|
-
finished: `run #${
|
|
2247
|
-
"not-live": `run #${
|
|
2248
|
-
publication: `run #${
|
|
2293
|
+
"no-run": `there is no run #${named}`,
|
|
2294
|
+
"wrong-task": `run #${named} is not one of this task's attempts`,
|
|
2295
|
+
finished: `run #${named} already ended before the stop — nothing rewrites a finished attempt; reload to see the current state`,
|
|
2296
|
+
"not-live": `run #${named} is not the attempt holding this task now — reload and decide against the current attempt`,
|
|
2297
|
+
publication: `run #${named} already admitted its publication — an external request in flight is not recalled by a stop`,
|
|
2249
2298
|
};
|
|
2250
2299
|
return taskScreen(response, who, taskId, words[asked.reason] ?? asked.detail, 409);
|
|
2251
2300
|
}
|
|
@@ -2334,7 +2383,7 @@ export function createTasksHandlers(runtime) {
|
|
|
2334
2383
|
capacity: one.capacity,
|
|
2335
2384
|
building: building.get(one.name) ?? 0,
|
|
2336
2385
|
}));
|
|
2337
|
-
return queueBody(tasks, workers,
|
|
2386
|
+
return queueBody(tasks, workers, revision, store.queueRevision());
|
|
2338
2387
|
}
|
|
2339
2388
|
/** An OBSERVED CI failure on a completed row's PR — the open episode the
|
|
2340
2389
|
* watcher recorded for exactly that repository and number; never a guess
|
|
@@ -2612,58 +2661,78 @@ export function createTasksHandlers(runtime) {
|
|
|
2612
2661
|
live.add(run.id);
|
|
2613
2662
|
return live;
|
|
2614
2663
|
}
|
|
2615
|
-
const registrations =
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2664
|
+
const registrations = handlersOf("tasks", {
|
|
2665
|
+
"code.page": showCode("workspace"),
|
|
2666
|
+
"code.session": showCode("session"),
|
|
2667
|
+
"code.state": showCode("state"),
|
|
2668
|
+
"code.changes": showCode("changes"),
|
|
2669
|
+
"code.ship": showCode("ship"),
|
|
2670
|
+
"code.other": showCode("unknown"),
|
|
2671
|
+
"inbox": showInbox,
|
|
2672
|
+
"work": showWork,
|
|
2673
|
+
"next": showNext,
|
|
2674
|
+
"board": showBoard,
|
|
2675
|
+
"review": showReview,
|
|
2676
|
+
"done": showDone,
|
|
2677
|
+
"tasks": showTasks,
|
|
2678
|
+
"queue": showQueue,
|
|
2679
|
+
"tasks.new": showNewTask,
|
|
2680
|
+
"task.page": showTask,
|
|
2681
|
+
"task.evidence": showTaskEvidence,
|
|
2682
|
+
"runs": showRuns,
|
|
2683
|
+
"run.page": showRun,
|
|
2684
|
+
"run.evidence": showRunEvidence,
|
|
2685
|
+
"routines": showRoutines,
|
|
2686
|
+
"routine.page": showRoutine,
|
|
2687
|
+
"decision.page": showDecision,
|
|
2688
|
+
"decision.evidence": showDecisionEvidence,
|
|
2689
|
+
"code.start": codeAct("start"),
|
|
2690
|
+
"code.send-send": codeAct("send"),
|
|
2691
|
+
"code.stop-send": codeAct("stop"),
|
|
2692
|
+
"code.resume-send": codeAct("resume"),
|
|
2693
|
+
"code.recover-send": codeAct("recover"),
|
|
2694
|
+
"code.continue-send": codeAct("continue"),
|
|
2695
|
+
"code.ship-send": codeAct("ship"),
|
|
2696
|
+
"code.answer-send": codeAct("answer"),
|
|
2697
|
+
"code.act-other": codeAct("unknown"),
|
|
2698
|
+
"tasks.add": addTask,
|
|
2699
|
+
"queue.move": queueMove,
|
|
2700
|
+
"queue.note": queueNote,
|
|
2701
|
+
"decision.answer": answerDecision,
|
|
2702
|
+
"task.act.hold": taskAct("hold"),
|
|
2703
|
+
"task.act.unhold": taskAct("unhold"),
|
|
2704
|
+
"task.act.requeue": taskAct("requeue"),
|
|
2705
|
+
"task.act.cancel": taskAct("cancel"),
|
|
2706
|
+
"task.act.scope": taskAct("scope"),
|
|
2707
|
+
"task.act.approve": taskAct("approve"),
|
|
2708
|
+
"task.act.plan": taskAct("plan"),
|
|
2709
|
+
"task.act.plan-edit": taskAct("plan-edit"),
|
|
2710
|
+
"task.act.next": taskAct("next"),
|
|
2711
|
+
"task.act.reopen": taskAct("reopen"),
|
|
2712
|
+
"task.act.steer": taskAct("steer"),
|
|
2713
|
+
"task.act.accept-proof": taskAct("accept-proof"),
|
|
2714
|
+
"task.act.accept-revision": taskAct("accept-revision"),
|
|
2715
|
+
"task.act.reject-revision": taskAct("reject-revision"),
|
|
2716
|
+
"task.act.route": taskAct("route"),
|
|
2717
|
+
"task.act.retry-review": taskAct("retry-review"),
|
|
2718
|
+
"task.act.complete": taskAct("complete"),
|
|
2719
|
+
"task.act.merge": taskMerge,
|
|
2720
|
+
"task.act.confirm-stopped": taskConfirmStopped,
|
|
2721
|
+
"task.act.stop": taskAct("stop"),
|
|
2722
|
+
"task.act.resume-arm": taskAct("resume-arm"),
|
|
2723
|
+
"task.act.resume": taskAct("resume"),
|
|
2724
|
+
"task.act.block": taskAct("block"),
|
|
2725
|
+
"task.act.unblock": taskAct("unblock"),
|
|
2726
|
+
"task.act.repair-dependency": taskAct("repair-dependency"),
|
|
2727
|
+
"task.act.follow-up": taskAct("follow-up"),
|
|
2728
|
+
"run.act.note": runNote,
|
|
2729
|
+
"run.act.comment": runComment,
|
|
2730
|
+
"run.act.revise": runRevise,
|
|
2731
|
+
"run.act.draft-repair": runDraftRepair,
|
|
2732
|
+
"run.act.checks": runFollowUp("checks"),
|
|
2733
|
+
"run.act.add-tests": runFollowUp("add-tests"),
|
|
2734
|
+
"session.editor-links": sessionEditorLinks,
|
|
2735
|
+
"incident.resolve": resolveIncident,
|
|
2736
|
+
});
|
|
2737
|
+
return { registrations, runListPane, peekFragment, taskMutation, queueRegionFor, ciFailingFor, completedRowFor, attemptRowFor, resultRowOf, reviewCockpitViewOf, decisionEvidence, runEvidence, sendArtifact, peekGuards, liveRunIds };
|
|
2669
2738
|
}
|