@a-t-h-i/bot-lobby 0.6.45 → 0.6.46
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/package.json +1 -1
- package/src/execution/task-cancellation.ts +27 -0
- package/src/lobby/host.ts +4 -0
- package/src/lobby/prompts.ts +1 -2
- package/src/lobby/service.ts +77 -1
- package/src/state/persistence.ts +26 -1
- package/src/webui/api/index.ts +13 -6
- package/src/webui/api/tasks.ts +39 -2
- package/src/webui/palette-core.ts +233 -2
- package/src/webui/protocol.ts +6 -74
- package/src/workflow/workflow.ts +15 -3
- package/webui/dist/assets/index-BJ1fEle9.js +110 -0
- package/webui/dist/assets/index-BdtiIB9L.css +2 -0
- package/webui/dist/build.json +33 -61
- package/webui/dist/index.html +2 -2
- package/src/lobby/models/metrics.ts +0 -14
- package/src/webui/api/metrics.ts +0 -96
- package/webui/dist/assets/index-ClwTDiyo.css +0 -2
- package/webui/dist/assets/index-Ct47Kp7Q.js +0 -110
package/package.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
const executions = new Map<string, Set<AbortController>>();
|
|
4
|
+
const key = (root: string, configDir: string, taskId: string) => JSON.stringify([resolve(root), configDir, taskId]);
|
|
5
|
+
|
|
6
|
+
/** Scope an orchestration action and its workers/children to one project task. */
|
|
7
|
+
export function taskExecution(root: string, configDir: string, taskId: string, parent?: AbortSignal) {
|
|
8
|
+
const id = key(root, configDir, taskId);
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const running = executions.get(id) ?? new Set<AbortController>();
|
|
11
|
+
running.add(controller);
|
|
12
|
+
executions.set(id, running);
|
|
13
|
+
return {
|
|
14
|
+
signal: parent ? AbortSignal.any([parent, controller.signal]) : controller.signal,
|
|
15
|
+
release: () => {
|
|
16
|
+
running.delete(controller);
|
|
17
|
+
if (!running.size) executions.delete(id);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Abort only this task's orchestration and the subprocesses inheriting its signal. */
|
|
23
|
+
export function cancelTaskExecutions(root: string, configDir: string, taskId: string): void {
|
|
24
|
+
for (const controller of executions.get(key(root, configDir, taskId)) ?? []) {
|
|
25
|
+
controller.abort(new Error("Task abandoned by the user"));
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/lobby/host.ts
CHANGED
|
@@ -173,6 +173,10 @@ export interface LobbyService {
|
|
|
173
173
|
* background session. A notice, and the background session's key.
|
|
174
174
|
*/
|
|
175
175
|
resumeTask?(taskId: string): Promise<{ notice: string; key?: string }>;
|
|
176
|
+
acceptTask?(taskId: string): Promise<{ notice: string }>;
|
|
177
|
+
forceCloseTask?(taskId: string, title: string): Promise<{ notice: string }>;
|
|
178
|
+
canForceCloseTask?(taskId: string): boolean;
|
|
179
|
+
resolveTaskApproval?(taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }>;
|
|
176
180
|
/** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
|
|
177
181
|
sendToSession(sessionId: string, text: string): string;
|
|
178
182
|
/** Sessions running in this project, other than this window's, from their heartbeats. */
|
package/src/lobby/prompts.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `node:*`): the web page imports this file directly.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "git", "knowledge"
|
|
7
|
+
export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "git", "knowledge"] as const;
|
|
8
8
|
export type TabId = (typeof TAB_IDS)[number];
|
|
9
9
|
|
|
10
10
|
export const TAB_LABELS: Record<TabId, string> = {
|
|
@@ -12,7 +12,6 @@ export const TAB_LABELS: Record<TabId, string> = {
|
|
|
12
12
|
tasks: "Tasks",
|
|
13
13
|
plan: "Plan",
|
|
14
14
|
quickfix: "Quick fix",
|
|
15
|
-
metrics: "Metrics",
|
|
16
15
|
git: "Git",
|
|
17
16
|
knowledge: "Knowledge",
|
|
18
17
|
};
|
package/src/lobby/service.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { updateBlockingRequest } from "../state/blocking-requests.ts";
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { loadTask, ownedTask, peekOwnedTask, peekTasks, saveTask } from "../state/persistence.ts";
|
|
11
11
|
import { taskWork } from "../state/work-time.ts";
|
|
12
|
+
import { transition } from "../state/task-state.ts";
|
|
13
|
+
import { cancelTaskExecutions } from "../execution/task-cancellation.ts";
|
|
12
14
|
import { refreshReview, deferReview } from "../delivery/review.ts";
|
|
13
15
|
import { deliver, type DeliveryRequest } from "../delivery/operations.ts";
|
|
14
16
|
import { deliveryStore, deliveryTask } from "../delivery/store.ts";
|
|
@@ -18,7 +20,7 @@ import { loadConfig, readDataRoots, saveConfig as writeConfig } from "../state/p
|
|
|
18
20
|
import { knowledgeQuestion } from "./knowledge.ts";
|
|
19
21
|
import { addPlanComment, editComment, readPlanComments, type PlanComment } from "../state/comments.ts";
|
|
20
22
|
import { isAutoMode } from "../state/auto.ts";
|
|
21
|
-
import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
|
|
23
|
+
import { markInboxDelivered, sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
|
|
22
24
|
import { livePresence } from "../state/presence.ts";
|
|
23
25
|
import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
|
|
24
26
|
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
@@ -50,6 +52,9 @@ import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
|
|
|
50
52
|
import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
|
|
51
53
|
import type { Runtime } from "./runtime.ts";
|
|
52
54
|
import { findLinters } from "../execution/lint.ts";
|
|
55
|
+
import { handleResolveApproval, lastQaAsks, waiveLint, waiveQa } from "../workflow/workflow.ts";
|
|
56
|
+
import { recordDecision } from "../master/decisions.ts";
|
|
57
|
+
import { fail } from "../webui/api/index.ts";
|
|
53
58
|
|
|
54
59
|
/** The state background callbacks report to; set while the lobby service runs. */
|
|
55
60
|
let serviceState: Runtime | undefined;
|
|
@@ -384,6 +389,73 @@ async function resumeTask(state: Runtime, taskId: string): Promise<{ notice: str
|
|
|
384
389
|
}
|
|
385
390
|
}
|
|
386
391
|
|
|
392
|
+
function mutableTask(state: Runtime, taskId: string): Task {
|
|
393
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId)) fail(400, "bad_request", "invalid task ID");
|
|
394
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
395
|
+
if (!task) fail(404, "not_found", "task not found in this project");
|
|
396
|
+
if (TERMINAL_STATES.includes(task.state)) fail(400, "bad_request", "task is terminal");
|
|
397
|
+
return task;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function wakeTask(state: Runtime, task: Task, message: string): Promise<string> {
|
|
401
|
+
const inbox = sendToInbox(state.root, state.configDir, task.id, message, state.ctx.sessionManager.getSessionId());
|
|
402
|
+
const route = resumeRoute(task, taskDrivers(state));
|
|
403
|
+
if (route.kind === "stopped" || route.kind === "paused") return (await resumeTask(state, task.id)).notice;
|
|
404
|
+
if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) {
|
|
405
|
+
state.pi.sendUserMessage(message, state.ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
406
|
+
markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
|
|
407
|
+
} else {
|
|
408
|
+
const session = backgroundSessions().find((entry) => entry.alive && entry.projectRoot === state.root && entry.sessionId === task.ownerSessionId);
|
|
409
|
+
if (session) {
|
|
410
|
+
session.send(message);
|
|
411
|
+
markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return "the owning oracle has been notified";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function canForceCloseTask(state: Runtime, taskId: string): boolean {
|
|
418
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
419
|
+
return Boolean(task && !TERMINAL_STATES.includes(task.state) && (!task.ownerSessionId || task.ownerSessionId === state.ctx.sessionManager.getSessionId()));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function forceCloseTask(state: Runtime, taskId: string, title: string): Promise<{ notice: string }> {
|
|
423
|
+
const task = mutableTask(state, taskId);
|
|
424
|
+
if (title !== task.title) fail(400, "bad_request", "the title must match exactly");
|
|
425
|
+
if (!canForceCloseTask(state, taskId)) fail(400, "bad_request", "cancel this task in its owning session; remote cancellation is unavailable");
|
|
426
|
+
recordDecision(task, "The user force-closed the task from the Tasks screen; work abandoned, code and worktree retained.");
|
|
427
|
+
transition(task, "abandoned");
|
|
428
|
+
saveTask(state.root, state.configDir, task);
|
|
429
|
+
cancelTaskExecutions(state.root, state.configDir, task.id);
|
|
430
|
+
if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) state.ctx.abort();
|
|
431
|
+
lobbyTopics.bump("tasks");
|
|
432
|
+
return { notice: `${task.id} abandoned; code, worktree and history retained` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function acceptTask(state: Runtime, taskId: string): Promise<{ notice: string }> {
|
|
436
|
+
const task = mutableTask(state, taskId);
|
|
437
|
+
if (!["implementing", "reviewing", "blocked"].includes(task.state)) fail(400, "bad_request", "there is no work to accept yet");
|
|
438
|
+
const asks = lastQaAsks(task);
|
|
439
|
+
if (task.qaVerdict !== "pass" && !task.qaWaiver) waiveQa(task, asks, "from the Tasks screen");
|
|
440
|
+
if (task.lint?.state === "failing") waiveLint(task, "from the Tasks screen");
|
|
441
|
+
recordDecision(task, "The user accepted the work as it is from the Tasks screen; the oracle was asked to complete it.");
|
|
442
|
+
saveTask(state.root, state.configDir, task);
|
|
443
|
+
lobbyTopics.bump("tasks");
|
|
444
|
+
const wake = await wakeTask(state, task, `The user accepted ${task.id}'s work as it is. Call orchestrate action=complete now with a short summary naming outstanding QA/lint asks: ${asks.join("; ") || "none recorded"}. Read the persisted gate waivers first. Do not merge automatically.`);
|
|
445
|
+
return { notice: `${task.id} accepted as it is; ${wake}` };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function resolveTaskApproval(state: Runtime, taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }> {
|
|
449
|
+
const task = mutableTask(state, taskId);
|
|
450
|
+
if (!task.approvals.some((entry) => entry.id === approvalId && entry.status === "pending")) fail(409, "conflict", "approval is no longer pending");
|
|
451
|
+
let message: string;
|
|
452
|
+
try { message = handleResolveApproval(task, { action: "resolve_approval", approvalId, decision, note }); }
|
|
453
|
+
catch (error) { return fail(400, "bad_request", (error as Error).message); }
|
|
454
|
+
saveTask(state.root, state.configDir, task);
|
|
455
|
+
lobbyTopics.bump("tasks");
|
|
456
|
+
return { notice: `${message}; ${await wakeTask(state, task, message)}` };
|
|
457
|
+
}
|
|
458
|
+
|
|
387
459
|
/** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
|
|
388
460
|
function sendToTask(state: Runtime, taskId: string, text: string): string {
|
|
389
461
|
const task = loadTask(state.root, state.configDir, taskId);
|
|
@@ -826,6 +898,10 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
826
898
|
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
|
827
899
|
sendToTask: (taskId, text) => sendToTask(state, taskId, text),
|
|
828
900
|
resumeTask: (taskId) => resumeTask(state, taskId),
|
|
901
|
+
acceptTask: (taskId) => acceptTask(state, taskId),
|
|
902
|
+
forceCloseTask: (taskId, title) => forceCloseTask(state, taskId, title),
|
|
903
|
+
canForceCloseTask: (taskId) => canForceCloseTask(state, taskId),
|
|
904
|
+
resolveTaskApproval: (taskId, approvalId, decision, note) => resolveTaskApproval(state, taskId, approvalId, decision, note),
|
|
829
905
|
sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
|
|
830
906
|
liveSessions: () => liveSessions(state),
|
|
831
907
|
switchTo: (target) => switchTo(state, target),
|
package/src/state/persistence.ts
CHANGED
|
@@ -89,13 +89,38 @@ export function readTaskArtifact(
|
|
|
89
89
|
|
|
90
90
|
export function saveTask(root: string, configDir: string, task: Task): void {
|
|
91
91
|
const latest = loadTask(root, configDir, task.id);
|
|
92
|
-
|
|
92
|
+
// Abandonment is final: an in-flight action must not overwrite its audit or resurrect it.
|
|
93
|
+
if (latest?.state === "abandoned") {
|
|
94
|
+
Object.assign(task, latest);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (latest) {
|
|
98
|
+
preserveUserInterventions(task, latest);
|
|
99
|
+
mergePhaseTiming(task, timingBaselines.get(task), latest);
|
|
100
|
+
}
|
|
93
101
|
const path = join(taskDir(dataRoot(root, configDir), task.id), "state.json");
|
|
94
102
|
forgetCached(path);
|
|
95
103
|
writeFileEnsured(path, JSON.stringify(task, null, 2));
|
|
96
104
|
rememberTiming(task);
|
|
97
105
|
}
|
|
98
106
|
|
|
107
|
+
/** Explicit user waivers and resolved approvals must survive an in-flight worker's snapshot. */
|
|
108
|
+
function preserveUserInterventions(task: Task, latest: Task): void {
|
|
109
|
+
const newlyWaived = !task.qaWaiver && latest.qaWaiver;
|
|
110
|
+
task.qaWaiver ??= latest.qaWaiver;
|
|
111
|
+
task.lintWaiver ??= latest.lintWaiver;
|
|
112
|
+
if (newlyWaived && !TERMINAL_STATES.includes(task.state)) {
|
|
113
|
+
task.blockers = [];
|
|
114
|
+
if (["implementing", "blocked"].includes(task.state)) task.state = "reviewing";
|
|
115
|
+
}
|
|
116
|
+
for (const approval of task.approvals) {
|
|
117
|
+
const resolved = latest.approvals.find((entry) => entry.id === approval.id && entry.status !== "pending");
|
|
118
|
+
if (approval.status === "pending" && resolved) Object.assign(approval, resolved);
|
|
119
|
+
}
|
|
120
|
+
const keys = new Set(task.decisions.map((entry) => JSON.stringify(entry)));
|
|
121
|
+
task.decisions.push(...latest.decisions.filter((entry) => !keys.has(JSON.stringify(entry))));
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
/** Read a task state: the bot-lobby copy wins, else the newest pre-rename copy. */
|
|
100
125
|
export function loadTask(root: string, configDir: string, taskId: string): Task | undefined {
|
|
101
126
|
for (const dr of readDataRoots(root, configDir)) {
|
package/src/webui/api/index.ts
CHANGED
|
@@ -15,8 +15,7 @@ import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
|
|
|
15
15
|
import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
|
|
16
16
|
import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerEditMessage, plannerToggleSeat, plannerSave, plannerPrevious, plannerPreviousArchive, plannerPreviousDelete, plannerPreviousGet, plannerPreviousOpen } from "./planner.ts";
|
|
17
17
|
import { quickfixCancel, quickfixList, quickfixMovedToTask, quickfixRunAnyway, quickfixSubmit } from "./quickfix.ts";
|
|
18
|
-
import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver } from "./tasks.ts";
|
|
19
|
-
import { metricsGet } from "./metrics.ts";
|
|
18
|
+
import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver, tasksAccept, tasksForceClose, tasksResolveApproval } from "./tasks.ts";
|
|
20
19
|
import { knowledgeAdd, knowledgeAsk, knowledgeComment, knowledgeEdit, knowledgeFiles, knowledgeOpen, knowledgeRemove, knowledgeReplaceFile, knowledgeUnnote } from "./knowledge.ts";
|
|
21
20
|
import { plansDiscard, plansGet, plansStart } from "./plans.ts";
|
|
22
21
|
import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts";
|
|
@@ -131,6 +130,18 @@ function buildRoutes(): Record<string, Route> {
|
|
|
131
130
|
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
132
131
|
run: (body, ctx) => tasksResume(body as { taskId: string }, ctx),
|
|
133
132
|
};
|
|
133
|
+
routes["tasks.accept"] = {
|
|
134
|
+
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
135
|
+
run: (body, ctx) => tasksAccept(body as { taskId: string }, ctx),
|
|
136
|
+
};
|
|
137
|
+
routes["tasks.forceClose"] = {
|
|
138
|
+
schema: Type.Object({ taskId: TaskId, title: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
|
|
139
|
+
run: (body, ctx) => tasksForceClose(body as { taskId: string; title: string }, ctx),
|
|
140
|
+
};
|
|
141
|
+
routes["tasks.resolveApproval"] = {
|
|
142
|
+
schema: Type.Object({ taskId: TaskId, approvalId: TaskId, decision: Type.Union([Type.Literal("approved"), Type.Literal("rejected")]), note: Type.Optional(NoticeText) }, { additionalProperties: false }),
|
|
143
|
+
run: (body, ctx) => tasksResolveApproval(body as Parameters<typeof tasksResolveApproval>[0], ctx),
|
|
144
|
+
};
|
|
134
145
|
routes["tasks.deliveryReview"] = {
|
|
135
146
|
schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
|
|
136
147
|
run: (body, ctx) => tasksDeliveryReview(body as { taskId: string }, ctx),
|
|
@@ -268,10 +279,6 @@ function buildRoutes(): Record<string, Route> {
|
|
|
268
279
|
schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
|
|
269
280
|
run: (body, ctx) => quickfixMovedToTask(body as { id: string }, ctx),
|
|
270
281
|
};
|
|
271
|
-
routes["metrics.get"] = {
|
|
272
|
-
schema: Type.Object({ groupBy: Type.Union([Type.Literal("model"), Type.Literal("model-kind")]), query: Type.Optional(Type.String({ maxLength: 500 })), from: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), to: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), timeZone: Type.Optional(Type.String({ maxLength: 100 })) }, { additionalProperties: false }),
|
|
273
|
-
run: (body, ctx) => metricsGet(body as Parameters<typeof metricsGet>[0], ctx),
|
|
274
|
-
};
|
|
275
282
|
routes["knowledge.files"] = { schema: Empty, run: (_body, ctx) => knowledgeFiles(ctx) };
|
|
276
283
|
routes["knowledge.ask"] = {
|
|
277
284
|
schema: Type.Object({ text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -282,17 +282,52 @@ export function tasksOpen(body: { taskId: string }, ctx: ApiContext): { sessionI
|
|
|
282
282
|
return { notice: "The task's owning session has ended or is unavailable. No replacement session was created." };
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/** Interventions only apply to an active task in this project, never an archive. */
|
|
286
|
+
function interventionTask(taskId: string, ctx: ApiContext): Task {
|
|
287
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId)) fail(400, "bad_request", "invalid task ID");
|
|
288
|
+
const task = ctx.service.tasks().find((entry) => entry.id === taskId);
|
|
289
|
+
if (!task) fail(404, "not_found", "task not found in this project");
|
|
290
|
+
if (TERMINAL_STATES.includes(task.state)) fail(400, "bad_request", "this task is already finished");
|
|
291
|
+
return task;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function acceptable(task: Task): boolean {
|
|
295
|
+
return ["implementing", "reviewing", "blocked"].includes(task.state);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function tasksAccept(body: { taskId: string }, ctx: ApiContext): Promise<{ notice: string }> {
|
|
299
|
+
const task = interventionTask(body.taskId, ctx);
|
|
300
|
+
if (!acceptable(task)) fail(400, "bad_request", "only implementing, reviewing or blocked tasks can be accepted");
|
|
301
|
+
if (!ctx.service.acceptTask) fail(400, "bad_request", "accepting tasks is unavailable");
|
|
302
|
+
return ctx.service.acceptTask(body.taskId);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function tasksForceClose(body: { taskId: string; title: string }, ctx: ApiContext): Promise<{ notice: string }> {
|
|
306
|
+
const task = interventionTask(body.taskId, ctx);
|
|
307
|
+
if (body.title !== task.title) fail(400, "bad_request", "the title must match exactly");
|
|
308
|
+
if (!ctx.service.forceCloseTask) fail(400, "bad_request", "force closing tasks is unavailable");
|
|
309
|
+
return ctx.service.forceCloseTask(body.taskId, body.title);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function tasksResolveApproval(body: { taskId: string; approvalId: string; decision: "approved" | "rejected"; note?: string }, ctx: ApiContext): Promise<{ notice: string }> {
|
|
313
|
+
const task = interventionTask(body.taskId, ctx);
|
|
314
|
+
if (!pendingApprovals(task).some((approval) => approval.id === body.approvalId)) fail(409, "conflict", "approval is no longer pending; refresh the task");
|
|
315
|
+
if (!ctx.service.resolveTaskApproval) fail(400, "bad_request", "resolving task approvals is unavailable");
|
|
316
|
+
return ctx.service.resolveTaskApproval(body.taskId, body.approvalId, body.decision, body.note);
|
|
317
|
+
}
|
|
318
|
+
|
|
285
319
|
/** How many recent runs the detail lists (the terminal's count). */
|
|
286
320
|
const DETAIL_RUNS = 6;
|
|
287
321
|
|
|
288
322
|
/** The terminal's "Waiting on" approvals: `kind` plus `for <domain>: <detail>`. */
|
|
289
323
|
function waitingOf(task: Task): TaskDetail["waiting"] {
|
|
290
|
-
return pendingApprovals(task).map((approval) => ({ kind: approval.kind, detail: `for ${approval.domain}: ${approval.detail}` }));
|
|
324
|
+
return pendingApprovals(task).map((approval) => ({ id: approval.id, domain: approval.domain, kind: approval.kind, detail: `for ${approval.domain}: ${approval.detail}` }));
|
|
291
325
|
}
|
|
292
326
|
|
|
293
327
|
/** One task read whole: request, plan or proposal, step checklist, waits and recent runs. */
|
|
294
328
|
export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail {
|
|
295
|
-
const
|
|
329
|
+
const active = ctx.service.tasks().find((entry) => entry.id === body.taskId);
|
|
330
|
+
const task = active ?? ctx.service.archivedTasks().find((entry) => entry.id === body.taskId);
|
|
296
331
|
if (!task) fail(404, "not_found", `no task ${body.taskId}`);
|
|
297
332
|
const found = task as Task;
|
|
298
333
|
const request = taskRequest(found);
|
|
@@ -301,6 +336,8 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
|
|
|
301
336
|
const clock = clockOf(found, work);
|
|
302
337
|
const details = found.plan ? planDetails(found.plan) : undefined;
|
|
303
338
|
return {
|
|
339
|
+
canAccept: Boolean(active && ctx.service.acceptTask) && acceptable(found),
|
|
340
|
+
canForceClose: Boolean(active && ctx.service.forceCloseTask) && !TERMINAL_STATES.includes(found.state) && (ctx.service.canForceCloseTask?.(found.id) ?? true),
|
|
304
341
|
timing: projectPhaseTiming(found, new Date(now).toISOString()),
|
|
305
342
|
...(clock ? { work: clock } : {}),
|
|
306
343
|
...(found.delivery ? { delivery: found.delivery } : {}),
|
|
@@ -174,10 +174,11 @@ export const DEFAULT_ID = "mist"
|
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
176
|
* The themes that come with the page. The first is the page's own colours and
|
|
177
|
-
* sets nothing;
|
|
177
|
+
* sets nothing; Blossom through Iris are T3 Code's built-in themes (MIT, Copyright (c) 2026
|
|
178
178
|
* T3 Tools Inc., packages/shared/src/themePalettes.ts), their roles mapped onto
|
|
179
179
|
* the shadcn variables: canvas is the background, surface the card, accent the
|
|
180
|
-
* primary, accent surface the accent, error the destructive colour.
|
|
180
|
+
* primary, accent surface the accent, error the destructive colour. Slate,
|
|
181
|
+
* Sand, Rosewood, Aurora and Midnight are original complementary palettes.
|
|
181
182
|
*/
|
|
182
183
|
export const PRESETS: readonly Palette[] = [
|
|
183
184
|
{ id: DEFAULT_ID, name: "Default" },
|
|
@@ -411,6 +412,236 @@ export const PRESETS: readonly Palette[] = [
|
|
|
411
412
|
"chart-1": "oklch(0.671712 0.169136 293.929)",
|
|
412
413
|
},
|
|
413
414
|
},
|
|
415
|
+
{
|
|
416
|
+
id: "slate",
|
|
417
|
+
name: "Slate",
|
|
418
|
+
light: {
|
|
419
|
+
"background": "#f5f7fa",
|
|
420
|
+
"foreground": "#202b3b",
|
|
421
|
+
"card": "#ffffff",
|
|
422
|
+
"card-foreground": "#202b3b",
|
|
423
|
+
"popover": "#ffffff",
|
|
424
|
+
"popover-foreground": "#202b3b",
|
|
425
|
+
"primary": "#465b78",
|
|
426
|
+
"primary-foreground": "#ffffff",
|
|
427
|
+
"secondary": "#e7ecf2",
|
|
428
|
+
"secondary-foreground": "#202b3b",
|
|
429
|
+
"muted": "#edf1f6",
|
|
430
|
+
"muted-foreground": "#526177",
|
|
431
|
+
"accent": "#e0e7ef",
|
|
432
|
+
"accent-foreground": "#202b3b",
|
|
433
|
+
"destructive": "#a52a34",
|
|
434
|
+
"border": "#cad3df",
|
|
435
|
+
"input": "#b9c5d4",
|
|
436
|
+
"ring": "#465b78",
|
|
437
|
+
"chart-1": "#465b78",
|
|
438
|
+
},
|
|
439
|
+
dark: {
|
|
440
|
+
"background": "#141b26",
|
|
441
|
+
"foreground": "#e8edf5",
|
|
442
|
+
"card": "#1c2533",
|
|
443
|
+
"card-foreground": "#e8edf5",
|
|
444
|
+
"popover": "#202b3b",
|
|
445
|
+
"popover-foreground": "#e8edf5",
|
|
446
|
+
"primary": "#adc0dc",
|
|
447
|
+
"primary-foreground": "#141b26",
|
|
448
|
+
"secondary": "#2a3648",
|
|
449
|
+
"secondary-foreground": "#e8edf5",
|
|
450
|
+
"muted": "#243043",
|
|
451
|
+
"muted-foreground": "#afbdd0",
|
|
452
|
+
"accent": "#33445d",
|
|
453
|
+
"accent-foreground": "#e8edf5",
|
|
454
|
+
"destructive": "#fb8990",
|
|
455
|
+
"border": "#43536a",
|
|
456
|
+
"input": "#53647d",
|
|
457
|
+
"ring": "#adc0dc",
|
|
458
|
+
"chart-1": "#adc0dc",
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
id: "sand",
|
|
463
|
+
name: "Sand",
|
|
464
|
+
light: {
|
|
465
|
+
"background": "#faf7f1",
|
|
466
|
+
"foreground": "#352b22",
|
|
467
|
+
"card": "#fffdf8",
|
|
468
|
+
"card-foreground": "#352b22",
|
|
469
|
+
"popover": "#fffdf8",
|
|
470
|
+
"popover-foreground": "#352b22",
|
|
471
|
+
"primary": "#765536",
|
|
472
|
+
"primary-foreground": "#ffffff",
|
|
473
|
+
"secondary": "#eee6da",
|
|
474
|
+
"secondary-foreground": "#352b22",
|
|
475
|
+
"muted": "#f2ece2",
|
|
476
|
+
"muted-foreground": "#6c5b49",
|
|
477
|
+
"accent": "#e9ddcb",
|
|
478
|
+
"accent-foreground": "#352b22",
|
|
479
|
+
"destructive": "#a52a34",
|
|
480
|
+
"border": "#d9cbb8",
|
|
481
|
+
"input": "#c9b79e",
|
|
482
|
+
"ring": "#765536",
|
|
483
|
+
"chart-1": "#765536",
|
|
484
|
+
},
|
|
485
|
+
dark: {
|
|
486
|
+
"background": "#211c17",
|
|
487
|
+
"foreground": "#f4ece0",
|
|
488
|
+
"card": "#2c251e",
|
|
489
|
+
"card-foreground": "#f4ece0",
|
|
490
|
+
"popover": "#352c23",
|
|
491
|
+
"popover-foreground": "#f4ece0",
|
|
492
|
+
"primary": "#dcbc92",
|
|
493
|
+
"primary-foreground": "#211c17",
|
|
494
|
+
"secondary": "#3c3227",
|
|
495
|
+
"secondary-foreground": "#f4ece0",
|
|
496
|
+
"muted": "#352d24",
|
|
497
|
+
"muted-foreground": "#c9b9a5",
|
|
498
|
+
"accent": "#4a3b2b",
|
|
499
|
+
"accent-foreground": "#f4ece0",
|
|
500
|
+
"destructive": "#fb8990",
|
|
501
|
+
"border": "#64503e",
|
|
502
|
+
"input": "#79614b",
|
|
503
|
+
"ring": "#dcbc92",
|
|
504
|
+
"chart-1": "#dcbc92",
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
id: "rosewood",
|
|
509
|
+
name: "Rosewood",
|
|
510
|
+
light: {
|
|
511
|
+
"background": "#fbf6f5",
|
|
512
|
+
"foreground": "#3b262c",
|
|
513
|
+
"card": "#fffafa",
|
|
514
|
+
"card-foreground": "#3b262c",
|
|
515
|
+
"popover": "#fffafa",
|
|
516
|
+
"popover-foreground": "#3b262c",
|
|
517
|
+
"primary": "#854252",
|
|
518
|
+
"primary-foreground": "#ffffff",
|
|
519
|
+
"secondary": "#f0e2e5",
|
|
520
|
+
"secondary-foreground": "#3b262c",
|
|
521
|
+
"muted": "#f5eaec",
|
|
522
|
+
"muted-foreground": "#76525d",
|
|
523
|
+
"accent": "#edd6dd",
|
|
524
|
+
"accent-foreground": "#3b262c",
|
|
525
|
+
"destructive": "#a52a34",
|
|
526
|
+
"border": "#dfc5cc",
|
|
527
|
+
"input": "#d0afb9",
|
|
528
|
+
"ring": "#854252",
|
|
529
|
+
"chart-1": "#854252",
|
|
530
|
+
},
|
|
531
|
+
dark: {
|
|
532
|
+
"background": "#241a20",
|
|
533
|
+
"foreground": "#f6e8ed",
|
|
534
|
+
"card": "#30222a",
|
|
535
|
+
"card-foreground": "#f6e8ed",
|
|
536
|
+
"popover": "#3a2932",
|
|
537
|
+
"popover-foreground": "#f6e8ed",
|
|
538
|
+
"primary": "#e3a5b7",
|
|
539
|
+
"primary-foreground": "#241a20",
|
|
540
|
+
"secondary": "#442e38",
|
|
541
|
+
"secondary-foreground": "#f6e8ed",
|
|
542
|
+
"muted": "#3a2932",
|
|
543
|
+
"muted-foreground": "#d2b4c0",
|
|
544
|
+
"accent": "#553441",
|
|
545
|
+
"accent-foreground": "#f6e8ed",
|
|
546
|
+
"destructive": "#fb8990",
|
|
547
|
+
"border": "#70505d",
|
|
548
|
+
"input": "#856170",
|
|
549
|
+
"ring": "#e3a5b7",
|
|
550
|
+
"chart-1": "#e3a5b7",
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
id: "aurora",
|
|
555
|
+
name: "Aurora",
|
|
556
|
+
light: {
|
|
557
|
+
"background": "#f2faf8",
|
|
558
|
+
"foreground": "#193a37",
|
|
559
|
+
"card": "#fafffe",
|
|
560
|
+
"card-foreground": "#193a37",
|
|
561
|
+
"popover": "#fafffe",
|
|
562
|
+
"popover-foreground": "#193a37",
|
|
563
|
+
"primary": "#216c63",
|
|
564
|
+
"primary-foreground": "#ffffff",
|
|
565
|
+
"secondary": "#def0eb",
|
|
566
|
+
"secondary-foreground": "#193a37",
|
|
567
|
+
"muted": "#e6f2ef",
|
|
568
|
+
"muted-foreground": "#456c66",
|
|
569
|
+
"accent": "#eee5f6",
|
|
570
|
+
"accent-foreground": "#193a37",
|
|
571
|
+
"destructive": "#a52a34",
|
|
572
|
+
"border": "#bbd7d1",
|
|
573
|
+
"input": "#a7c8c0",
|
|
574
|
+
"ring": "#216c63",
|
|
575
|
+
"chart-1": "#216c63",
|
|
576
|
+
},
|
|
577
|
+
dark: {
|
|
578
|
+
"background": "#142321",
|
|
579
|
+
"foreground": "#e1f5ef",
|
|
580
|
+
"card": "#1d302d",
|
|
581
|
+
"card-foreground": "#e1f5ef",
|
|
582
|
+
"popover": "#253d38",
|
|
583
|
+
"popover-foreground": "#e1f5ef",
|
|
584
|
+
"primary": "#87d5c1",
|
|
585
|
+
"primary-foreground": "#142321",
|
|
586
|
+
"secondary": "#29443e",
|
|
587
|
+
"secondary-foreground": "#e1f5ef",
|
|
588
|
+
"muted": "#243c36",
|
|
589
|
+
"muted-foreground": "#a9cfc3",
|
|
590
|
+
"accent": "#3d3352",
|
|
591
|
+
"accent-foreground": "#e1f5ef",
|
|
592
|
+
"destructive": "#fb8990",
|
|
593
|
+
"border": "#466b60",
|
|
594
|
+
"input": "#568172",
|
|
595
|
+
"ring": "#87d5c1",
|
|
596
|
+
"chart-1": "#87d5c1",
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
id: "midnight",
|
|
601
|
+
name: "Midnight",
|
|
602
|
+
light: {
|
|
603
|
+
"background": "#f5f6fb",
|
|
604
|
+
"foreground": "#282e48",
|
|
605
|
+
"card": "#fcfcff",
|
|
606
|
+
"card-foreground": "#282e48",
|
|
607
|
+
"popover": "#fcfcff",
|
|
608
|
+
"popover-foreground": "#282e48",
|
|
609
|
+
"primary": "#4f5f96",
|
|
610
|
+
"primary-foreground": "#ffffff",
|
|
611
|
+
"secondary": "#e5e8f4",
|
|
612
|
+
"secondary-foreground": "#282e48",
|
|
613
|
+
"muted": "#eceef7",
|
|
614
|
+
"muted-foreground": "#565f7d",
|
|
615
|
+
"accent": "#dfe4f6",
|
|
616
|
+
"accent-foreground": "#282e48",
|
|
617
|
+
"destructive": "#a52a34",
|
|
618
|
+
"border": "#c7cee5",
|
|
619
|
+
"input": "#b4bfdc",
|
|
620
|
+
"ring": "#4f5f96",
|
|
621
|
+
"chart-1": "#4f5f96",
|
|
622
|
+
},
|
|
623
|
+
dark: {
|
|
624
|
+
"background": "#151a2b",
|
|
625
|
+
"foreground": "#e8ecfa",
|
|
626
|
+
"card": "#1f263b",
|
|
627
|
+
"card-foreground": "#e8ecfa",
|
|
628
|
+
"popover": "#29314a",
|
|
629
|
+
"popover-foreground": "#e8ecfa",
|
|
630
|
+
"primary": "#acbbed",
|
|
631
|
+
"primary-foreground": "#151a2b",
|
|
632
|
+
"secondary": "#303954",
|
|
633
|
+
"secondary-foreground": "#e8ecfa",
|
|
634
|
+
"muted": "#28314b",
|
|
635
|
+
"muted-foreground": "#b1bedf",
|
|
636
|
+
"accent": "#3a466a",
|
|
637
|
+
"accent-foreground": "#e8ecfa",
|
|
638
|
+
"destructive": "#fb8990",
|
|
639
|
+
"border": "#48577d",
|
|
640
|
+
"input": "#596990",
|
|
641
|
+
"ring": "#acbbed",
|
|
642
|
+
"chart-1": "#acbbed",
|
|
643
|
+
},
|
|
644
|
+
},
|
|
414
645
|
]
|
|
415
646
|
|
|
416
647
|
/** Colours for a preview swatch, per mode: the default's own values by hand, the rest from the theme. */
|