taskchef 7.20.0 → 7.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +26 -11
- package/docs/spec.md +57 -15
- package/docs/workflows.md +11 -9
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/dashboard/actions.js +81 -0
- package/src/dashboard/app.js +220 -13
- package/src/dashboard/index.html +13 -4
- package/src/dashboard/state.js +60 -3
- package/src/dashboard/styles.css +17 -4
- package/src/dashboard.js +116 -0
- package/src/mcp.js +19 -1
- package/src/usage-tracker.js +68 -13
- package/src/workspace.js +368 -20
package/src/dashboard/app.js
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
import {
|
|
2
2
|
canArchiveTask,
|
|
3
|
+
canManuallyTransitionTask,
|
|
3
4
|
clearNotifications,
|
|
4
5
|
dismissNotification,
|
|
5
6
|
filterTasks,
|
|
6
7
|
findCurrentTask,
|
|
7
8
|
latestTurnPresentation,
|
|
9
|
+
manualTransitionExpectedState,
|
|
8
10
|
mergeProjectedTurns,
|
|
9
11
|
nextDateFilterRefreshDelay,
|
|
10
12
|
notificationDismissLabel,
|
|
11
13
|
notificationOpenLabel,
|
|
12
14
|
notificationTitle,
|
|
13
15
|
reconcileNotifications,
|
|
16
|
+
reconcileManualTransition,
|
|
17
|
+
reconcileManualTransitionResponse,
|
|
14
18
|
statusFilterCounts,
|
|
15
19
|
statusFilterText,
|
|
16
20
|
taskStatusLabel,
|
|
21
|
+
taskMatchesManualTransitionExpected,
|
|
17
22
|
turnPresentation,
|
|
18
23
|
} from "./state.js";
|
|
19
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
archiveTaskFromControl,
|
|
26
|
+
focusManualTransitionStatus,
|
|
27
|
+
handleManualTransitionEscape,
|
|
28
|
+
manuallyTransitionTaskFromControl,
|
|
29
|
+
openTaskFromControl,
|
|
30
|
+
restoreTaskActionMenuFocus,
|
|
31
|
+
} from "./actions.js";
|
|
20
32
|
import {
|
|
21
33
|
githubReferenceAccessibleLabel,
|
|
22
34
|
githubReferenceDisplayLabels,
|
|
@@ -29,12 +41,14 @@ const USAGE_POLL_INTERVAL_MS = 1_500;
|
|
|
29
41
|
const MAX_USAGE_POLL_ATTEMPTS = 40;
|
|
30
42
|
|
|
31
43
|
const state = {
|
|
44
|
+
archivePendingThreadIds: new Set(),
|
|
32
45
|
archivedThreadIds: new Set(),
|
|
33
46
|
tasks: [],
|
|
34
47
|
signatures: new Map(),
|
|
35
48
|
notifications: [],
|
|
36
49
|
seenNotificationIds: new Set(),
|
|
37
50
|
initialized: false,
|
|
51
|
+
manualTransition: null,
|
|
38
52
|
selectedTask: null,
|
|
39
53
|
};
|
|
40
54
|
let dateRefreshTimer = null;
|
|
@@ -65,6 +79,12 @@ const elements = {
|
|
|
65
79
|
emptyState: document.querySelector("#empty-state"),
|
|
66
80
|
notifications: document.querySelector("#notifications"),
|
|
67
81
|
notificationAnnouncer: document.querySelector("#notification-announcer"),
|
|
82
|
+
manualTransitionPanel: document.querySelector("#manual-transition-panel"),
|
|
83
|
+
manualTransitionError: document.querySelector("#manual-transition-error"),
|
|
84
|
+
manualTransitionStatus: document.querySelector("#manual-transition-status"),
|
|
85
|
+
markTaskCompleted: document.querySelector("#mark-task-completed"),
|
|
86
|
+
markTaskFailed: document.querySelector("#mark-task-failed"),
|
|
87
|
+
moreTaskActions: document.querySelector("#more-task-actions"),
|
|
68
88
|
openProject: document.querySelector("#open-codex"),
|
|
69
89
|
projectFilter: document.querySelector("#project-filter"),
|
|
70
90
|
statusFilter: document.querySelector("#status-filter"),
|
|
@@ -426,6 +446,12 @@ function turnTimeline(task) {
|
|
|
426
446
|
key: `detail:${task.id}:turn:${turnKey}`,
|
|
427
447
|
});
|
|
428
448
|
header.append(status, timestamp);
|
|
449
|
+
if (presentation.sourceLabel) {
|
|
450
|
+
const source = document.createElement("span");
|
|
451
|
+
source.className = "result-history-source";
|
|
452
|
+
source.textContent = presentation.sourceLabel;
|
|
453
|
+
header.insertBefore(source, timestamp);
|
|
454
|
+
}
|
|
429
455
|
const requestLabel = document.createElement("h4");
|
|
430
456
|
requestLabel.textContent = "Request";
|
|
431
457
|
const request = document.createElement("p");
|
|
@@ -461,6 +487,139 @@ function turnTimeline(task) {
|
|
|
461
487
|
});
|
|
462
488
|
}
|
|
463
489
|
|
|
490
|
+
function manualTransitionPending() {
|
|
491
|
+
return state.manualTransition?.stage === "pending";
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function resetManualTransition({ focus = false } = {}) {
|
|
495
|
+
state.manualTransition = null;
|
|
496
|
+
elements.closeDialog.disabled = false;
|
|
497
|
+
if (state.selectedTask) renderManualTransition(state.selectedTask);
|
|
498
|
+
if (focus) elements.moreTaskActions.focus();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function replaceCurrentTask(task) {
|
|
502
|
+
state.tasks = state.tasks.map((candidate) => candidate.id === task.id ? task : candidate);
|
|
503
|
+
state.selectedTask = task;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function renderManualTransition(task) {
|
|
507
|
+
const activeElement = document.activeElement;
|
|
508
|
+
const focusWasInPanel = elements.manualTransitionPanel.contains?.(activeElement) ?? false;
|
|
509
|
+
const eligible = canManuallyTransitionTask(task);
|
|
510
|
+
state.manualTransition = reconcileManualTransition(state.manualTransition, task);
|
|
511
|
+
const pending = manualTransitionPending();
|
|
512
|
+
const expanded = Boolean(state.manualTransition);
|
|
513
|
+
elements.moreTaskActions.disabled = pending;
|
|
514
|
+
elements.moreTaskActions.textContent = expanded ? "←" : "…";
|
|
515
|
+
elements.moreTaskActions.setAttribute(
|
|
516
|
+
"aria-label",
|
|
517
|
+
expanded ? "Hide more task actions" : "More task actions",
|
|
518
|
+
);
|
|
519
|
+
elements.moreTaskActions.setAttribute("title", expanded ? "Hide more task actions" : "More task actions");
|
|
520
|
+
elements.moreTaskActions.setAttribute("aria-expanded", String(expanded));
|
|
521
|
+
elements.closeDialog.disabled = pending;
|
|
522
|
+
elements.manualTransitionPanel.hidden = state.manualTransition === null;
|
|
523
|
+
elements.manualTransitionPanel.setAttribute("aria-busy", String(pending));
|
|
524
|
+
elements.copyTaskId.disabled = pending || !task.id;
|
|
525
|
+
elements.markTaskCompleted.hidden = !eligible;
|
|
526
|
+
elements.markTaskFailed.hidden = !eligible;
|
|
527
|
+
elements.markTaskCompleted.disabled = pending;
|
|
528
|
+
elements.markTaskFailed.disabled = pending;
|
|
529
|
+
const archivePending = state.archivePendingThreadIds.has(task.threadId);
|
|
530
|
+
const archived = state.archivedThreadIds.has(task.threadId);
|
|
531
|
+
elements.archiveTask.disabled = pending || archivePending || archived;
|
|
532
|
+
elements.archiveTask.textContent = archived ? "Archived" : archivePending ? "Archiving…" : "Archive chat";
|
|
533
|
+
elements.archiveTask.setAttribute(
|
|
534
|
+
"aria-label",
|
|
535
|
+
archived
|
|
536
|
+
? `${task.title} is archived in Codex`
|
|
537
|
+
: archivePending
|
|
538
|
+
? `Archiving ${task.title} in Codex`
|
|
539
|
+
: `Archive ${task.title} in Codex`,
|
|
540
|
+
);
|
|
541
|
+
elements.manualTransitionStatus.hidden = !pending;
|
|
542
|
+
elements.manualTransitionStatus.textContent = pending ? "Saving task state…" : "";
|
|
543
|
+
const error = state.manualTransition?.error ?? "";
|
|
544
|
+
elements.manualTransitionError.hidden = !error;
|
|
545
|
+
elements.manualTransitionError.textContent = error;
|
|
546
|
+
if (!state.manualTransition) {
|
|
547
|
+
elements.closeDialog.disabled = false;
|
|
548
|
+
if (focusWasInPanel) elements.moreTaskActions.focus();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (pending) {
|
|
552
|
+
focusManualTransitionStatus(elements.manualTransitionPanel);
|
|
553
|
+
} else {
|
|
554
|
+
restoreTaskActionMenuFocus(
|
|
555
|
+
elements.manualTransitionPanel,
|
|
556
|
+
activeElement,
|
|
557
|
+
elements.moreTaskActions,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function submitManualTransition(targetStatus, event) {
|
|
563
|
+
const task = state.selectedTask;
|
|
564
|
+
if (!task || !canManuallyTransitionTask(task) || manualTransitionPending()) return;
|
|
565
|
+
const previous = state.manualTransition;
|
|
566
|
+
const actionId = previous?.targetStatus === targetStatus && previous.actionId
|
|
567
|
+
? previous.actionId
|
|
568
|
+
: crypto.randomUUID();
|
|
569
|
+
const attempt = {
|
|
570
|
+
...previous,
|
|
571
|
+
taskId: task.id,
|
|
572
|
+
stage: "pending",
|
|
573
|
+
targetStatus,
|
|
574
|
+
actionId,
|
|
575
|
+
expected: previous?.expected ?? manualTransitionExpectedState(task),
|
|
576
|
+
error: null,
|
|
577
|
+
};
|
|
578
|
+
state.manualTransition = attempt;
|
|
579
|
+
renderManualTransition(task);
|
|
580
|
+
const result = await manuallyTransitionTaskFromControl(
|
|
581
|
+
event,
|
|
582
|
+
{ ...task, ...attempt.expected },
|
|
583
|
+
targetStatus,
|
|
584
|
+
attempt.actionId,
|
|
585
|
+
);
|
|
586
|
+
const current = reconcileManualTransitionResponse({
|
|
587
|
+
requestTask: task,
|
|
588
|
+
expected: attempt.expected,
|
|
589
|
+
responseTask: result.task,
|
|
590
|
+
selectedTask: state.selectedTask,
|
|
591
|
+
});
|
|
592
|
+
if (result.ok) {
|
|
593
|
+
state.manualTransition = null;
|
|
594
|
+
if (current === result.task) replaceCurrentTask(result.task);
|
|
595
|
+
renderDialog(current);
|
|
596
|
+
render();
|
|
597
|
+
showMessage(result.task.summary);
|
|
598
|
+
elements.dialogTitle.focus?.();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (current === result.task) replaceCurrentTask(result.task);
|
|
602
|
+
if (
|
|
603
|
+
!canManuallyTransitionTask(current)
|
|
604
|
+
|| !taskMatchesManualTransitionExpected(current, attempt.expected)
|
|
605
|
+
) {
|
|
606
|
+
state.manualTransition = null;
|
|
607
|
+
renderDialog(current);
|
|
608
|
+
render();
|
|
609
|
+
showMessage("This task changed. Review its current state before trying again.");
|
|
610
|
+
elements.dialogTitle.focus?.();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
state.manualTransition = {
|
|
614
|
+
...attempt,
|
|
615
|
+
stage: "choose",
|
|
616
|
+
error: result.message,
|
|
617
|
+
actionId: result.code === "stale_task" ? crypto.randomUUID() : attempt.actionId,
|
|
618
|
+
};
|
|
619
|
+
renderDialog(current);
|
|
620
|
+
elements.manualTransitionError.focus();
|
|
621
|
+
}
|
|
622
|
+
|
|
464
623
|
function renderDialog(task) {
|
|
465
624
|
if (state.selectedTask?.id !== task.id) {
|
|
466
625
|
copyTaskIdGeneration += 1;
|
|
@@ -526,13 +685,15 @@ function renderDialog(task) {
|
|
|
526
685
|
configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
|
|
527
686
|
const canArchive = canArchiveTask(task);
|
|
528
687
|
const archived = state.archivedThreadIds.has(task.threadId);
|
|
688
|
+
const archivePending = state.archivePendingThreadIds.has(task.threadId);
|
|
529
689
|
elements.archiveTask.hidden = !canArchive;
|
|
530
|
-
elements.archiveTask.disabled = archived;
|
|
531
|
-
elements.archiveTask.textContent = archived ? "Archived" : "Archive chat";
|
|
690
|
+
elements.archiveTask.disabled = archived || archivePending;
|
|
691
|
+
elements.archiveTask.textContent = archived ? "Archived" : archivePending ? "Archiving…" : "Archive chat";
|
|
532
692
|
elements.archiveTask.setAttribute(
|
|
533
693
|
"aria-label",
|
|
534
694
|
archived ? `${task.title} is archived in Codex` : `Archive ${task.title} in Codex`,
|
|
535
695
|
);
|
|
696
|
+
renderManualTransition(detailedTask);
|
|
536
697
|
}
|
|
537
698
|
|
|
538
699
|
async function openDialog(task) {
|
|
@@ -715,9 +876,47 @@ elements.clearNotifications.addEventListener("click", () => {
|
|
|
715
876
|
state.notifications = clearNotifications();
|
|
716
877
|
renderNotifications();
|
|
717
878
|
});
|
|
718
|
-
elements.closeDialog.addEventListener("click", () =>
|
|
879
|
+
elements.closeDialog.addEventListener("click", () => {
|
|
880
|
+
if (!manualTransitionPending()) elements.dialog.close();
|
|
881
|
+
});
|
|
719
882
|
elements.dialog.addEventListener("click", (event) => {
|
|
720
|
-
if (event.target === elements.dialog) elements.dialog.close();
|
|
883
|
+
if (event.target === elements.dialog && !manualTransitionPending()) elements.dialog.close();
|
|
884
|
+
});
|
|
885
|
+
elements.dialog.addEventListener("keydown", (event) => {
|
|
886
|
+
handleManualTransitionEscape(event, {
|
|
887
|
+
active: Boolean(state.manualTransition),
|
|
888
|
+
pending: manualTransitionPending(),
|
|
889
|
+
cancel: () => resetManualTransition({ focus: true }),
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
elements.dialog.addEventListener("cancel", (event) => {
|
|
893
|
+
if (!state.manualTransition) return;
|
|
894
|
+
event.preventDefault();
|
|
895
|
+
if (!manualTransitionPending()) resetManualTransition({ focus: true });
|
|
896
|
+
});
|
|
897
|
+
elements.dialog.addEventListener("close", () => {
|
|
898
|
+
if (!manualTransitionPending()) state.manualTransition = null;
|
|
899
|
+
});
|
|
900
|
+
elements.moreTaskActions.addEventListener("click", () => {
|
|
901
|
+
const task = state.selectedTask;
|
|
902
|
+
if (!task || manualTransitionPending()) return;
|
|
903
|
+
if (state.manualTransition) {
|
|
904
|
+
resetManualTransition({ focus: true });
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
state.manualTransition = {
|
|
908
|
+
taskId: task.id,
|
|
909
|
+
stage: "choose",
|
|
910
|
+
expected: manualTransitionExpectedState(task),
|
|
911
|
+
};
|
|
912
|
+
renderManualTransition(task);
|
|
913
|
+
elements.copyTaskId.focus();
|
|
914
|
+
});
|
|
915
|
+
elements.markTaskCompleted.addEventListener("click", (event) => {
|
|
916
|
+
return submitManualTransition("completed", event);
|
|
917
|
+
});
|
|
918
|
+
elements.markTaskFailed.addEventListener("click", (event) => {
|
|
919
|
+
return submitManualTransition("failed", event);
|
|
721
920
|
});
|
|
722
921
|
elements.copyTaskId.addEventListener("click", async () => {
|
|
723
922
|
const taskId = state.selectedTask?.id;
|
|
@@ -747,12 +946,20 @@ elements.openProject.addEventListener("click", async (event) => {
|
|
|
747
946
|
});
|
|
748
947
|
elements.archiveTask.addEventListener("click", async (event) => {
|
|
749
948
|
const task = state.selectedTask;
|
|
750
|
-
if (
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
949
|
+
if (
|
|
950
|
+
!task
|
|
951
|
+
|| !canArchiveTask(task)
|
|
952
|
+
|| state.archivePendingThreadIds.has(task.threadId)
|
|
953
|
+
) return;
|
|
954
|
+
state.archivePendingThreadIds.add(task.threadId);
|
|
955
|
+
renderManualTransition(task);
|
|
956
|
+
try {
|
|
957
|
+
await archiveTaskFromControl(event, task, {
|
|
958
|
+
onArchived: (threadId) => state.archivedThreadIds.add(threadId),
|
|
959
|
+
showMessage,
|
|
960
|
+
});
|
|
961
|
+
} finally {
|
|
962
|
+
state.archivePendingThreadIds.delete(task.threadId);
|
|
963
|
+
if (state.selectedTask?.id === task.id) renderDialog(state.selectedTask);
|
|
964
|
+
}
|
|
758
965
|
});
|
package/src/dashboard/index.html
CHANGED
|
@@ -100,11 +100,11 @@
|
|
|
100
100
|
<div class="dialog-header">
|
|
101
101
|
<div>
|
|
102
102
|
<p id="dialog-project" class="eyebrow"></p>
|
|
103
|
-
<h2 id="dialog-title"></h2>
|
|
103
|
+
<h2 id="dialog-title" tabindex="-1"></h2>
|
|
104
104
|
</div>
|
|
105
105
|
<button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
|
|
106
106
|
</div>
|
|
107
|
-
<div class="dialog-actions">
|
|
107
|
+
<div class="dialog-actions" role="group" aria-label="Task actions">
|
|
108
108
|
<button id="open-codex" class="primary-button task-action" type="button" aria-label="Open this task in Codex">
|
|
109
109
|
<picture class="codex-icon" aria-hidden="true">
|
|
110
110
|
<source srcset="/assets/codex-app-dark.png" media="(prefers-color-scheme: dark)">
|
|
@@ -112,8 +112,17 @@
|
|
|
112
112
|
</picture>
|
|
113
113
|
<span>Open task</span>
|
|
114
114
|
</button>
|
|
115
|
-
<button id="
|
|
116
|
-
<
|
|
115
|
+
<button id="more-task-actions" class="secondary-button more-task-actions" type="button" aria-label="More task actions" aria-expanded="false" aria-controls="manual-transition-panel">…</button>
|
|
116
|
+
<div id="manual-transition-panel" class="manual-transition-panel" aria-busy="false" hidden>
|
|
117
|
+
<div class="manual-transition-controls">
|
|
118
|
+
<button id="copy-task-id" class="secondary-button" type="button" aria-label="Copy Task ID" data-manual-focus="copy">Copy Task ID</button>
|
|
119
|
+
<button id="mark-task-completed" class="secondary-button" type="button" data-manual-focus="target-completed">Mark completed</button>
|
|
120
|
+
<button id="mark-task-failed" class="danger-button" type="button" data-manual-focus="target-failed">Mark failed</button>
|
|
121
|
+
<button id="archive-codex" class="danger-button" type="button" aria-label="Archive this chat in Codex" hidden>Archive chat</button>
|
|
122
|
+
</div>
|
|
123
|
+
<p id="manual-transition-status" class="manual-transition-pending" role="status" aria-live="polite" tabindex="-1" data-manual-focus="pending" hidden></p>
|
|
124
|
+
<p id="manual-transition-error" class="manual-transition-error" role="alert" tabindex="-1" data-manual-focus="error" hidden></p>
|
|
125
|
+
</div>
|
|
117
126
|
</div>
|
|
118
127
|
<nav id="dialog-related-links" class="github-links" aria-label="Related GitHub links" hidden></nav>
|
|
119
128
|
<section>
|
package/src/dashboard/state.js
CHANGED
|
@@ -22,6 +22,8 @@ const NOTIFICATION_TITLES = new Map([
|
|
|
22
22
|
["completed", "Task completed"],
|
|
23
23
|
["needs_input", "Task needs input"],
|
|
24
24
|
["failed", "Task failed"],
|
|
25
|
+
["manual_completed", "Task manually completed"],
|
|
26
|
+
["manual_failed", "Task manually failed"],
|
|
25
27
|
["unresolved", "Task updated"],
|
|
26
28
|
]);
|
|
27
29
|
|
|
@@ -45,6 +47,50 @@ export function canArchiveTask(task) {
|
|
|
45
47
|
&& CODEX_THREAD_ID_PATTERN.test(task.threadId);
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
export function canManuallyTransitionTask(task) {
|
|
51
|
+
return ["working", "needs_input"].includes(task.status);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function manualTransitionExpectedState(task) {
|
|
55
|
+
return {
|
|
56
|
+
status: task.status,
|
|
57
|
+
turnRef: task.turnRef,
|
|
58
|
+
threadId: task.threadId,
|
|
59
|
+
updatedAt: task.updatedAt,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function taskMatchesManualTransitionExpected(task, expected) {
|
|
64
|
+
return Boolean(expected)
|
|
65
|
+
&& task.status === expected.status
|
|
66
|
+
&& task.turnRef === expected.turnRef
|
|
67
|
+
&& task.threadId === expected.threadId
|
|
68
|
+
&& task.updatedAt === expected.updatedAt;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function reconcileManualTransition(transition, task) {
|
|
72
|
+
if (!transition || transition.taskId !== task.id) return null;
|
|
73
|
+
if (transition.stage === "pending") return transition;
|
|
74
|
+
if (taskMatchesManualTransitionExpected(task, transition.expected)) return transition;
|
|
75
|
+
return {
|
|
76
|
+
taskId: task.id,
|
|
77
|
+
stage: "choose",
|
|
78
|
+
expected: manualTransitionExpectedState(task),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function reconcileManualTransitionResponse({
|
|
83
|
+
requestTask,
|
|
84
|
+
expected,
|
|
85
|
+
responseTask,
|
|
86
|
+
selectedTask,
|
|
87
|
+
}) {
|
|
88
|
+
const current = selectedTask?.id === requestTask.id ? selectedTask : requestTask;
|
|
89
|
+
return taskMatchesManualTransitionExpected(current, expected)
|
|
90
|
+
? (responseTask ?? current)
|
|
91
|
+
: current;
|
|
92
|
+
}
|
|
93
|
+
|
|
48
94
|
export function latestTurnPresentation(task) {
|
|
49
95
|
const turn = task.latestTurn ?? null;
|
|
50
96
|
const result = turn?.result ?? null;
|
|
@@ -70,6 +116,9 @@ export function turnPresentation(turn) {
|
|
|
70
116
|
status: result?.status ?? "working",
|
|
71
117
|
summary: result?.summary ?? "In progress",
|
|
72
118
|
updatedAt: result?.updatedAt ?? turn.startedAt,
|
|
119
|
+
...(turn.provenance?.kind === "dashboard_manual"
|
|
120
|
+
? { sourceLabel: "Manual dashboard change" }
|
|
121
|
+
: {}),
|
|
73
122
|
};
|
|
74
123
|
}
|
|
75
124
|
|
|
@@ -200,6 +249,9 @@ function lifecycleEvent(task) {
|
|
|
200
249
|
if (task.status === "working") {
|
|
201
250
|
return task.lastResult ? "follow_up_started" : "task_started";
|
|
202
251
|
}
|
|
252
|
+
if (task.latestTurn?.provenance?.kind === "dashboard_manual") {
|
|
253
|
+
return `manual_${task.status}`;
|
|
254
|
+
}
|
|
203
255
|
return task.status ?? "unresolved";
|
|
204
256
|
}
|
|
205
257
|
|
|
@@ -223,7 +275,9 @@ function eventTimestamp(task, event) {
|
|
|
223
275
|
}
|
|
224
276
|
|
|
225
277
|
function eventSummary(task, event) {
|
|
226
|
-
if (!["completed", "needs_input", "failed"].includes(event))
|
|
278
|
+
if (!["completed", "needs_input", "failed", "manual_completed", "manual_failed"].includes(event)) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
227
281
|
if (
|
|
228
282
|
task.lastResult?.status === task.status
|
|
229
283
|
&& (task.lastResult?.turnRef ?? task.lastResult?.turnId)
|
|
@@ -252,16 +306,19 @@ export function notificationSnapshot(task, event = lifecycleEvent(task)) {
|
|
|
252
306
|
function resultNotificationSnapshot(task) {
|
|
253
307
|
const result = task.lastResult;
|
|
254
308
|
if (!result) return null;
|
|
309
|
+
const event = result.provenance?.kind === "dashboard_manual"
|
|
310
|
+
? `manual_${result.status}`
|
|
311
|
+
: result.status;
|
|
255
312
|
return Object.freeze({
|
|
256
313
|
id: notificationIdentity({
|
|
257
314
|
...task,
|
|
258
315
|
turnRef: result.turnRef ?? result.turnId,
|
|
259
316
|
turnId: result.turnId,
|
|
260
|
-
},
|
|
317
|
+
}, event),
|
|
261
318
|
taskId: task.id,
|
|
262
319
|
title: task.title,
|
|
263
320
|
status: result.status,
|
|
264
|
-
event
|
|
321
|
+
event,
|
|
265
322
|
turnRef: result.turnRef ?? result.turnId ?? null,
|
|
266
323
|
turnId: result.turnId ?? null,
|
|
267
324
|
timestamp: result.updatedAt,
|
package/src/dashboard/styles.css
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
--danger: #8b3a35;
|
|
12
12
|
--warning: #8a5c17;
|
|
13
13
|
--shadow: 0 12px 30px rgb(24 33 29 / 10%);
|
|
14
|
+
--control-height: 38px;
|
|
15
|
+
--control-padding-inline: 11px;
|
|
16
|
+
--control-radius: 7px;
|
|
17
|
+
--control-font-size: 0.86rem;
|
|
14
18
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -112,7 +116,7 @@ time, .timestamp-missing { font-size: 0.75rem; }
|
|
|
112
116
|
.timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
|
|
113
117
|
.task-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
|
114
118
|
.task-open { flex: none; }
|
|
115
|
-
.primary-button.task-action, .secondary-button.task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height:
|
|
119
|
+
.primary-button.task-action, .secondary-button.task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: var(--control-height); padding: 0 var(--control-padding-inline); font-size: var(--control-font-size); line-height: 1; white-space: nowrap; }
|
|
116
120
|
.codex-icon { display: block; flex: none; width: 18px; height: 18px; }
|
|
117
121
|
.codex-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
118
122
|
|
|
@@ -148,8 +152,15 @@ dialog { width: min(760px, calc(100vw - 32px)); max-height: min(82vh, 900px); pa
|
|
|
148
152
|
dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px); }
|
|
149
153
|
.dialog-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
|
150
154
|
.dialog-header h2 { margin-bottom: 12px; font-size: 1.6rem; }
|
|
151
|
-
.dialog-actions { display: flex; gap: 8px; margin-bottom: 26px; }
|
|
152
|
-
.
|
|
155
|
+
.dialog-actions { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 8px; margin-bottom: 26px; }
|
|
156
|
+
.more-task-actions { width: var(--control-height); min-width: var(--control-height); padding-inline: 0; font-size: 1.2rem; line-height: 1; }
|
|
157
|
+
.manual-transition-panel, .manual-transition-controls { display: contents; }
|
|
158
|
+
.manual-transition-panel[hidden] { display: none; }
|
|
159
|
+
.manual-transition-error, .manual-transition-pending { flex-basis: 100%; margin: 0; }
|
|
160
|
+
.manual-transition-controls > button[hidden] { display: none; }
|
|
161
|
+
.manual-transition-error { color: var(--danger); font-size: 0.86rem; }
|
|
162
|
+
.manual-transition-pending { color: var(--muted); font-size: 0.86rem; }
|
|
163
|
+
.primary-button, .secondary-button, .danger-button { display: inline-flex; align-items: center; justify-content: center; min-height: var(--control-height); padding: 0 var(--control-padding-inline); border-radius: var(--control-radius); font-size: var(--control-font-size); font-weight: 700; line-height: 1; cursor: pointer; }
|
|
153
164
|
.primary-button { border: 1px solid var(--accent); background: var(--accent); color: white; }
|
|
154
165
|
.secondary-button { border: 1px solid var(--border); background: var(--surface); }
|
|
155
166
|
.danger-button { border: 1px solid var(--danger); background: var(--surface); color: var(--danger); }
|
|
@@ -160,6 +171,8 @@ dialog section + section { margin-top: 24px; }
|
|
|
160
171
|
.result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
|
|
161
172
|
.result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
|
|
162
173
|
.result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
|
|
174
|
+
.result-history-header .timestamp-toggle { margin-left: auto; }
|
|
175
|
+
.result-history-source { color: var(--muted); font-size: 0.72rem; font-weight: 700; }
|
|
163
176
|
.result-history-item p { margin-bottom: 7px; }
|
|
164
177
|
.result-history-item p:last-child { margin-bottom: 0; }
|
|
165
178
|
.result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
@@ -182,6 +195,7 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
|
|
|
182
195
|
:focus-visible { outline: 3px solid rgb(33 95 74 / 35%); outline-offset: 3px; }
|
|
183
196
|
|
|
184
197
|
@media (max-width: 650px) {
|
|
198
|
+
:root { --control-height: 40px; }
|
|
185
199
|
.site-header { align-items: flex-start; gap: 14px; padding-top: 28px; }
|
|
186
200
|
.title-row { align-items: flex-start; }
|
|
187
201
|
.toolbar-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
@@ -196,7 +210,6 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
|
|
|
196
210
|
.task-open { align-self: flex-start; }
|
|
197
211
|
select { min-height: 40px; }
|
|
198
212
|
.status-filter-option span { min-height: 38px; }
|
|
199
|
-
.primary-button.task-action, .secondary-button.task-action { min-height: 40px; }
|
|
200
213
|
.dialog-actions { align-items: center; flex-flow: row wrap; }
|
|
201
214
|
.metadata { grid-template-columns: 1fr; }
|
|
202
215
|
.metadata dt { padding-bottom: 0; border-bottom: 0; }
|
package/src/dashboard.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
acquireWorkspaceLock,
|
|
17
17
|
canonicalDirectory,
|
|
18
18
|
canonicalGitRoot,
|
|
19
|
+
manuallyTransitionTask,
|
|
19
20
|
parseTaskLogContent,
|
|
20
21
|
readConfig,
|
|
21
22
|
readTask,
|
|
@@ -31,6 +32,7 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
|
|
|
31
32
|
const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
32
33
|
const DEFAULT_MAX_TASKS = 2_000;
|
|
33
34
|
const DEFAULT_MAX_EVENT_CLIENTS = 16;
|
|
35
|
+
const MAX_MANUAL_TRANSITION_BODY_BYTES = 4 * 1024;
|
|
34
36
|
export const DASHBOARD_HEALTH_PATH = "/api/health";
|
|
35
37
|
export const DASHBOARD_HEALTH_MAX_BYTES = 8 * 1024;
|
|
36
38
|
const CONTENT_SECURITY_POLICY = [
|
|
@@ -160,6 +162,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
160
162
|
boundedText(turn.turnRef, 512, `${name} turn ${turnIndex + 1} turn ref`);
|
|
161
163
|
boundedText(turn.turnId, 512, `${name} turn ${turnIndex + 1} turn ID`);
|
|
162
164
|
boundedText(turn.result?.summary, 2_000, `${name} turn ${turnIndex + 1} result summary`);
|
|
165
|
+
boundedText(
|
|
166
|
+
turn.provenance?.actionId,
|
|
167
|
+
512,
|
|
168
|
+
`${name} turn ${turnIndex + 1} manual action ID`,
|
|
169
|
+
);
|
|
163
170
|
}
|
|
164
171
|
const results = task.results ?? [];
|
|
165
172
|
if (results.length > 10_000) {
|
|
@@ -169,6 +176,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
169
176
|
boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
|
|
170
177
|
boundedText(result.turnRef, 512, `${name} result ${resultIndex + 1} turn ref`);
|
|
171
178
|
boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
|
|
179
|
+
boundedText(
|
|
180
|
+
result.provenance?.actionId,
|
|
181
|
+
512,
|
|
182
|
+
`${name} result ${resultIndex + 1} manual action ID`,
|
|
183
|
+
);
|
|
172
184
|
}
|
|
173
185
|
boundedText(task.project.name, 1_000, `${name} project name`);
|
|
174
186
|
boundedText(task.project.path, 8_192, `${name} project path`);
|
|
@@ -383,6 +395,79 @@ function sendJson(response, status, value) {
|
|
|
383
395
|
response.end(`${JSON.stringify(value)}\n`);
|
|
384
396
|
}
|
|
385
397
|
|
|
398
|
+
async function readBoundedJsonBody(request, maximumBytes = MAX_MANUAL_TRANSITION_BODY_BYTES) {
|
|
399
|
+
const contentType = request.headers["content-type"] ?? "";
|
|
400
|
+
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
|
|
401
|
+
const error = new Error("Request content type must be application/json.");
|
|
402
|
+
error.code = "unsupported_media_type";
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
const chunks = [];
|
|
406
|
+
let total = 0;
|
|
407
|
+
for await (const chunk of request) {
|
|
408
|
+
total += chunk.length;
|
|
409
|
+
if (total > maximumBytes) {
|
|
410
|
+
const error = new Error("Request body is too large.");
|
|
411
|
+
error.code = "body_too_large";
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
chunks.push(chunk);
|
|
415
|
+
}
|
|
416
|
+
let value;
|
|
417
|
+
try {
|
|
418
|
+
value = JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
|
|
419
|
+
} catch {
|
|
420
|
+
const error = new Error("Request body must be valid JSON.");
|
|
421
|
+
error.code = "malformed_json";
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
425
|
+
const error = new Error("Request body must be a JSON object.");
|
|
426
|
+
error.code = "invalid_request";
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
return value;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function manualTransitionInput(body) {
|
|
433
|
+
const fields = new Set(["schemaVersion", "actionId", "expected", "targetStatus"]);
|
|
434
|
+
const unexpected = Object.keys(body).find((key) => !fields.has(key));
|
|
435
|
+
const missing = [...fields].find((key) => !(key in body));
|
|
436
|
+
if (unexpected || missing || body.schemaVersion !== 1) {
|
|
437
|
+
const error = new Error("Manual transition request has an invalid shape.");
|
|
438
|
+
error.code = "invalid_request";
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
const { schemaVersion: _schemaVersion, ...input } = body;
|
|
442
|
+
return input;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function manualTransitionErrorResponse(error) {
|
|
446
|
+
const definitions = new Map([
|
|
447
|
+
["malformed_json", [400, "Request body must be valid JSON."]],
|
|
448
|
+
["invalid_request", [400, "Manual transition request is invalid."]],
|
|
449
|
+
["task_not_found", [404, "Task not found."]],
|
|
450
|
+
["stale_task", [409, "This task changed. Review its current state and try again."]],
|
|
451
|
+
["invalid_transition", [409, "This task can no longer be changed manually."]],
|
|
452
|
+
["idempotency_conflict", [409, "This manual action ID was already used."]],
|
|
453
|
+
["body_too_large", [413, "Request body is too large."]],
|
|
454
|
+
["unsupported_media_type", [415, "Request content type must be application/json."]],
|
|
455
|
+
["ELOCKED", [503, "TaskChef is busy updating the workspace. Try again."]],
|
|
456
|
+
]);
|
|
457
|
+
const definition = definitions.get(error?.code);
|
|
458
|
+
const [status, message] = definition ?? [500, "Dashboard request failed."];
|
|
459
|
+
return {
|
|
460
|
+
status,
|
|
461
|
+
body: {
|
|
462
|
+
code: error?.code === "ELOCKED"
|
|
463
|
+
? "workspace_busy"
|
|
464
|
+
: (definition ? error.code : "dashboard_error"),
|
|
465
|
+
message,
|
|
466
|
+
...(error?.task ? { task: taskDetailProjection(error.task) } : {}),
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
386
471
|
function ssePayload(event, value) {
|
|
387
472
|
return `event: ${event}\ndata: ${JSON.stringify(value)}\n\n`;
|
|
388
473
|
}
|
|
@@ -591,6 +676,37 @@ export async function createDashboardServer({
|
|
|
591
676
|
return;
|
|
592
677
|
}
|
|
593
678
|
|
|
679
|
+
const transitionMatch = url.pathname.match(
|
|
680
|
+
/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/manual-transition$/,
|
|
681
|
+
);
|
|
682
|
+
if (transitionMatch && method === "POST") {
|
|
683
|
+
if (request.headers.origin !== allowedOrigin) {
|
|
684
|
+
sendJson(response, 403, {
|
|
685
|
+
code: "invalid_origin",
|
|
686
|
+
message: "Dashboard origin validation failed.",
|
|
687
|
+
});
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
const body = await readBoundedJsonBody(request);
|
|
692
|
+
const result = await manuallyTransitionTask(
|
|
693
|
+
monitor.workspace,
|
|
694
|
+
transitionMatch[1],
|
|
695
|
+
manualTransitionInput(body),
|
|
696
|
+
);
|
|
697
|
+
await monitor.refresh({ force: true }).catch(() => {});
|
|
698
|
+
sendJson(response, 200, {
|
|
699
|
+
schemaVersion: 1,
|
|
700
|
+
task: taskDetailProjection(result.task),
|
|
701
|
+
idempotent: result.idempotent,
|
|
702
|
+
});
|
|
703
|
+
} catch (error) {
|
|
704
|
+
const failure = manualTransitionErrorResponse(error);
|
|
705
|
+
sendJson(response, failure.status, failure.body);
|
|
706
|
+
}
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
594
710
|
const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
|
|
595
711
|
if (taskMatch && method === "POST") {
|
|
596
712
|
if (request.headers.origin !== allowedOrigin) {
|