@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5
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/README.i18n.yaml +2 -2
- package/README.md +42 -11
- package/README.zh.md +42 -11
- package/lib/client.js +4958 -472
- package/lib/client.js.map +1 -1
- package/lib/index.js +2841 -121
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +4 -1
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/board-card-seat.d.ts +34 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +103 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +37 -1
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/tasks.d.ts +84 -3
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification.d.ts +386 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +52 -8
- package/lib/types/host-runner.d.ts +23 -1
- package/lib/types/host-service.d.ts +88 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +49 -5
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +12 -7
- package/src/client/board/NewTaskModal.tsx +17 -1
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +71 -26
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +203 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/status-key.ts +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +43 -5
- package/src/client/locales.ts +208 -4
- package/src/client/native-panel.tsx +18 -2
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +94 -3
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/store.ts +16 -0
- package/src/core/tasks.ts +100 -6
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +10 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +20 -18
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +143 -32
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +60 -11
- package/src/host-service.ts +253 -10
- package/src/index.ts +204 -16
- package/src/protocol.ts +102 -8
- package/src/tool-surface.ts +95 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-observed workspace changes, as acceptance evidence.
|
|
3
|
+
*
|
|
4
|
+
* The judge must not take the agent's word for what changed on disk: a patch
|
|
5
|
+
* that was never applied, or a file edited again afterwards, looks identical to
|
|
6
|
+
* a real edit when only the tool-call arguments are read. The DSH host records
|
|
7
|
+
* what a turn actually changed and serves it through the optional
|
|
8
|
+
* @@workspaceChanges@@ service, so that observation is offered next to the
|
|
9
|
+
* trajectory instead of being trusted from the transcript.
|
|
10
|
+
*
|
|
11
|
+
* The service is described STRUCTURALLY and resolved optionally: a deployment
|
|
12
|
+
* that serves none (or a cohort whose shape differs) degrades to "no extra
|
|
13
|
+
* evidence" and never breaks an acceptance that has already spent judge budget.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
|
|
17
|
+
/** The subset of the host change service this module drives. */
|
|
18
|
+
export interface WorkspaceChangeSource {
|
|
19
|
+
summary(sessionId: string, seq: number): unknown
|
|
20
|
+
diff(sessionId: string, seq: number, index: number, signal: AbortSignal): Promise<unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One session event, as the host log carries it. */
|
|
24
|
+
interface RawEvent {
|
|
25
|
+
type?: unknown
|
|
26
|
+
seq?: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** How many changed files one evidence block renders. */
|
|
30
|
+
export const MAX_WORKSPACE_FILES = 8
|
|
31
|
+
/** Character ceiling for the whole rendered block. */
|
|
32
|
+
export const WORKSPACE_EVIDENCE_MAX_CHARS = 24_000
|
|
33
|
+
/** Character ceiling for one file's rendered comparison. */
|
|
34
|
+
export const WORKSPACE_EVIDENCE_MAX_FILE_CHARS = 6_000
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the optional host change service from a context-like object.
|
|
38
|
+
* @param ctx - anything exposing @@get(name)@@.
|
|
39
|
+
* @returns the service, or undefined when this deployment serves none.
|
|
40
|
+
*/
|
|
41
|
+
export function probeWorkspaceChanges(ctx: { get(name: string): unknown }): WorkspaceChangeSource | undefined {
|
|
42
|
+
let service: unknown
|
|
43
|
+
try {
|
|
44
|
+
service = ctx.get('workspaceChanges')
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
if (typeof service !== 'object' || service === null) return undefined
|
|
49
|
+
const candidate = service as { summary?: unknown; diff?: unknown }
|
|
50
|
+
if (typeof candidate.summary !== 'function' || typeof candidate.diff !== 'function') return undefined
|
|
51
|
+
return service as WorkspaceChangeSource
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read a finite number, else 0. */
|
|
55
|
+
function count(value: unknown): number {
|
|
56
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Read a string, else ''. */
|
|
60
|
+
function text(value: unknown): string {
|
|
61
|
+
return typeof value === 'string' ? value : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bound one text to a character budget, keeping the head. */
|
|
65
|
+
function bound(value: string, maxChars: number): string {
|
|
66
|
+
if (value.length <= maxChars) return value
|
|
67
|
+
const notice = '\n[Truncated ' + (value.length - maxChars) + ' characters]'
|
|
68
|
+
if (notice.length >= maxChars) return value.slice(0, maxChars)
|
|
69
|
+
return value.slice(0, maxChars - notice.length) + notice
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The sequence of the newest change event inside the reviewed window. */
|
|
73
|
+
export function latestChangeEvent(events: readonly RawEvent[]): { seq: number; turn?: number } | undefined {
|
|
74
|
+
let latest: { seq: number; turn?: number } | undefined
|
|
75
|
+
for (const event of events) {
|
|
76
|
+
if (event.type !== 'workspace/changes') continue
|
|
77
|
+
if (typeof event.seq !== 'number') continue
|
|
78
|
+
if (latest === undefined || event.seq >= latest.seq) latest = { seq: event.seq }
|
|
79
|
+
}
|
|
80
|
+
return latest
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render one file's comparison from whatever shape the service answered. */
|
|
84
|
+
function renderDiff(value: unknown, maxChars: number): string {
|
|
85
|
+
if (typeof value !== 'object' || value === null) return '[no comparison available]'
|
|
86
|
+
const diff = value as Record<string, unknown>
|
|
87
|
+
if (diff.binary === true || text(diff.kind) === 'binary') return '[binary file]'
|
|
88
|
+
if (diff.oversized === true) return '[file too large to compare]'
|
|
89
|
+
const patch = text(diff.patch)
|
|
90
|
+
if (patch !== '') return bound(patch, maxChars)
|
|
91
|
+
const hunks = Array.isArray(diff.hunks) ? diff.hunks : []
|
|
92
|
+
const lines: string[] = []
|
|
93
|
+
for (const entry of hunks) {
|
|
94
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
95
|
+
const hunk = entry as Record<string, unknown>
|
|
96
|
+
lines.push('@@ -' + count(hunk.oldStart) + ',' + count(hunk.oldLines) + ' +' + count(hunk.newStart) + ',' + count(hunk.newLines) + ' @@')
|
|
97
|
+
const body = Array.isArray(hunk.lines) ? hunk.lines : []
|
|
98
|
+
for (const line of body) {
|
|
99
|
+
if (typeof line === 'string') lines.push(line)
|
|
100
|
+
else if (typeof line === 'object' && line !== null) {
|
|
101
|
+
const row = line as Record<string, unknown>
|
|
102
|
+
const marker = text(row.kind) === 'add' ? '+' : text(row.kind) === 'delete' ? '-' : ' '
|
|
103
|
+
lines.push(marker + text(row.text))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return lines.length === 0 ? '' : bound(lines.join('\n'), maxChars)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Render the host's own record of what the reviewed window changed.
|
|
112
|
+
*
|
|
113
|
+
* Bounded twice — at most {@link MAX_WORKSPACE_FILES} files, and a shared
|
|
114
|
+
* character budget split across them — so one enormous generated file cannot
|
|
115
|
+
* crowd out every other change. Every failure degrades to less evidence (or an
|
|
116
|
+
* empty block) instead of an error, because the acceptance being prepared must
|
|
117
|
+
* still reach a verdict.
|
|
118
|
+
* @param events - session events already bounded to the execution's window.
|
|
119
|
+
* @param sessionId - the session whose changes these are.
|
|
120
|
+
* @param source - the probed host service.
|
|
121
|
+
* @param options - budget, cancellation and the diagnostic sink.
|
|
122
|
+
* @returns the rendered block plus how many files it shows.
|
|
123
|
+
*/
|
|
124
|
+
export async function renderWorkspaceEvidence(
|
|
125
|
+
events: readonly RawEvent[],
|
|
126
|
+
sessionId: string,
|
|
127
|
+
source: WorkspaceChangeSource,
|
|
128
|
+
options: { signal: AbortSignal; warn?: (message: string) => void; maxChars?: number },
|
|
129
|
+
): Promise<{ text: string; files: number; hash: string }> {
|
|
130
|
+
const warn = options.warn ?? ((): void => {})
|
|
131
|
+
const maxChars = options.maxChars ?? WORKSPACE_EVIDENCE_MAX_CHARS
|
|
132
|
+
try {
|
|
133
|
+
const event = latestChangeEvent(events)
|
|
134
|
+
if (event === undefined) return { text: '', files: 0, hash: '' }
|
|
135
|
+
const summary = source.summary(sessionId, event.seq)
|
|
136
|
+
if (typeof summary !== 'object' || summary === null) return { text: '', files: 0, hash: '' }
|
|
137
|
+
const view = summary as Record<string, unknown>
|
|
138
|
+
const listed = (Array.isArray(view.files) ? view.files : [])
|
|
139
|
+
.map((file, index) => ({ file, index }))
|
|
140
|
+
.filter((entry): entry is { file: Record<string, unknown>, index: number } => typeof entry.file === 'object' && entry.file !== null)
|
|
141
|
+
if (listed.length === 0) return { text: '', files: 0, hash: '' }
|
|
142
|
+
const shown = listed.slice(0, MAX_WORKSPACE_FILES)
|
|
143
|
+
const added = count(view.added)
|
|
144
|
+
const deleted = count(view.deleted)
|
|
145
|
+
const header = 'Host-recorded workspace changes'
|
|
146
|
+
+ (typeof view.turn === 'number' ? ' for turn ' + view.turn : '')
|
|
147
|
+
+ ': ' + listed.length + ' changed file(s)'
|
|
148
|
+
+ (added > 0 || deleted > 0 ? ' (+' + added + '/-' + deleted + ' lines)' : '')
|
|
149
|
+
+ (listed.length > shown.length ? '; showing the first ' + shown.length : '')
|
|
150
|
+
+ '.\nObserved by the DSH host from the workspace itself, not reported by the agent.'
|
|
151
|
+
const perFile = Math.max(1, Math.min(WORKSPACE_EVIDENCE_MAX_FILE_CHARS, Math.floor((maxChars - header.length) / shown.length) - 40))
|
|
152
|
+
const sections: string[] = []
|
|
153
|
+
for (const entry of shown) {
|
|
154
|
+
const path = text(entry.file.path) !== '' ? text(entry.file.path) : text(entry.file.display) !== '' ? text(entry.file.display) : 'file'
|
|
155
|
+
const fileAdded = count(entry.file.added)
|
|
156
|
+
const fileDeleted = count(entry.file.deleted)
|
|
157
|
+
let comparison = ''
|
|
158
|
+
try {
|
|
159
|
+
comparison = renderDiff(await source.diff(sessionId, event.seq, entry.index, options.signal), perFile)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
comparison = '[comparison unavailable]'
|
|
162
|
+
warn('task-board workspace evidence unavailable for one file: ' + (error instanceof Error ? error.message : String(error)))
|
|
163
|
+
}
|
|
164
|
+
sections.push('- ' + path + (fileAdded > 0 || fileDeleted > 0 ? ' (+' + fileAdded + '/-' + fileDeleted + ')' : '') + '\n' + comparison)
|
|
165
|
+
}
|
|
166
|
+
const rendered = bound([header, ...sections].join('\n'), maxChars)
|
|
167
|
+
return {
|
|
168
|
+
text: rendered,
|
|
169
|
+
files: shown.length,
|
|
170
|
+
hash: createHash('sha256').update(rendered).digest('hex'),
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
warn('task-board workspace evidence unavailable: ' + (error instanceof Error ? error.message : String(error)))
|
|
174
|
+
return { text: '', files: 0, hash: '' }
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/host-ai.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* stripped, the first JSON object wins, and an unusable reply falls back to
|
|
10
10
|
* the user's own words instead of an empty form.
|
|
11
11
|
*/
|
|
12
|
-
import { createUserMessage, type
|
|
12
|
+
import { createUserMessage, type LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
13
|
+
import { openOneShotStream } from './host/llm-dispatch.ts'
|
|
13
14
|
import type { TaskBoardParseDraft, TaskBoardParseRequest } from './protocol.ts'
|
|
14
15
|
|
|
15
16
|
/** How long one parse may take before the Host gives up on the model. */
|
|
@@ -127,15 +128,20 @@ export async function parseTaskDraft(
|
|
|
127
128
|
const abortFromCaller = (): void => { timeout.abort() }
|
|
128
129
|
signal?.addEventListener('abort', abortFromCaller, { once: true })
|
|
129
130
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
// Same registration-bound dispatch as the acceptance judge: the public
|
|
132
|
+
// `llm.stream()` is a mutable instance property a third-party provider
|
|
133
|
+
// plugin may replace with its `llm/stream` listener signature.
|
|
134
|
+
const stream = await openOneShotStream(
|
|
135
|
+
llm,
|
|
136
|
+
{ provider: route.provider, model: route.model },
|
|
137
|
+
{
|
|
138
|
+
system: SYSTEM_PROMPT,
|
|
139
|
+
messages: [createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })],
|
|
140
|
+
signal: timeout.signal,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
137
143
|
let reply = ''
|
|
138
|
-
for await (const chunk of
|
|
144
|
+
for await (const chunk of stream) {
|
|
139
145
|
if (chunk.type === 'text-delta') reply += chunk.text
|
|
140
146
|
}
|
|
141
147
|
return draftFromReply(reply, text)
|
package/src/host-ledger.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
6
|
import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
|
|
7
7
|
import { isTaskRecord, parseLedger } from './core/store.ts'
|
|
8
|
-
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
8
|
+
import { canMoveManually, hasOpenExecution, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_SUBTASK_DEPTH,
|
|
11
11
|
cascadeTargets,
|
|
@@ -20,9 +20,11 @@ import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
|
20
20
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
21
21
|
import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
|
|
22
22
|
import { applySetParent } from './core/use-cases/task-parent.ts'
|
|
23
|
+
import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
|
|
23
24
|
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
25
|
+
import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
26
|
+
import type { ExecutionVerification } from './core/verification.ts'
|
|
27
|
+
import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
26
28
|
|
|
27
29
|
interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
|
|
28
30
|
importedSources?: string[]
|
|
@@ -57,7 +59,7 @@ function bindingRefusalMessage(
|
|
|
57
59
|
surface: 'run' | 'schedule',
|
|
58
60
|
): string {
|
|
59
61
|
if (refusal.kind === 'subtask-pin') {
|
|
60
|
-
return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session; clear
|
|
62
|
+
return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session and inherits its permission, which that pin exceeds; clear the card's permission or run the tree without Agent Team`
|
|
61
63
|
}
|
|
62
64
|
if (surface === 'schedule') return `task "${refusal.title}" has an unconfirmed above-default permission`
|
|
63
65
|
if (refusal.kind === 'root') {
|
|
@@ -127,10 +129,6 @@ function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
|
127
129
|
return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
function hasOpenExecution(task: TaskRecord): boolean {
|
|
131
|
-
return task.executions.some(execution => execution.endedAt === undefined)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
132
|
/**
|
|
135
133
|
* Process states that are dead but still occupy the PID table: `Z` (zombie)
|
|
136
134
|
* and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
|
|
@@ -341,7 +339,17 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
|
|
|
341
339
|
const previous = byId.get(entry.id)
|
|
342
340
|
byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
|
|
343
341
|
}
|
|
344
|
-
|
|
342
|
+
// Acceptance evidence is the one field "betterExecution" cannot choose: a
|
|
343
|
+
// browser half that also wrote this execution carries no attempts, and taking
|
|
344
|
+
// its copy would erase the Host's verdict. The copy with the most recorded
|
|
345
|
+
// attempts wins, and one execution's verdict is never copied onto another.
|
|
346
|
+
const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt).map(entry => {
|
|
347
|
+
if (entry.verification !== undefined) return entry
|
|
348
|
+
const richer = [...a.executions, ...b.executions]
|
|
349
|
+
.filter(other => other.id === entry.id && other.verification !== undefined)
|
|
350
|
+
.sort((x, y) => (y.verification?.attempts.length ?? 0) - (x.verification?.attempts.length ?? 0))[0]
|
|
351
|
+
return richer?.verification === undefined ? entry : { ...entry, verification: richer.verification }
|
|
352
|
+
})
|
|
345
353
|
return { ...newer, executions: retainRecentExecutions(executions) }
|
|
346
354
|
}
|
|
347
355
|
|
|
@@ -416,8 +424,18 @@ export class HostTaskLedger {
|
|
|
416
424
|
/** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
|
|
417
425
|
readonly schedulerFile: string
|
|
418
426
|
|
|
419
|
-
/**
|
|
420
|
-
readonly
|
|
427
|
+
/** Resolves the baseline the confirmation gate compares a binding against. */
|
|
428
|
+
private readonly sessionDefault: () => TaskPermission
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Session-default permission the confirmation gate compares against. Read
|
|
432
|
+
* through the resolver on every use, so a baseline that follows the Host's
|
|
433
|
+
* own default preset tracks a Settings change made while the board runs.
|
|
434
|
+
*/
|
|
435
|
+
get sessionDefaultPermission(): TaskPermission {
|
|
436
|
+
return this.sessionDefault()
|
|
437
|
+
}
|
|
438
|
+
|
|
421
439
|
/**
|
|
422
440
|
* Deployment subtask depth limit (1..3): the lineage gate every write obeys.
|
|
423
441
|
* The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
|
|
@@ -425,8 +443,10 @@ export class HostTaskLedger {
|
|
|
425
443
|
*/
|
|
426
444
|
private depthLimit: number
|
|
427
445
|
|
|
428
|
-
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
|
|
429
|
-
|
|
446
|
+
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number } = {}) {
|
|
447
|
+
// Resolved before the load/repair passes below: they can evaluate the gate.
|
|
448
|
+
const baseline = options.sessionDefaultPermission
|
|
449
|
+
this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
|
|
430
450
|
this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
|
|
431
451
|
mkdirSync(dir, { recursive: true })
|
|
432
452
|
this.file = join(dir, 'ledger-v2.json')
|
|
@@ -644,7 +664,7 @@ export class HostTaskLedger {
|
|
|
644
664
|
rollForward()
|
|
645
665
|
return []
|
|
646
666
|
}
|
|
647
|
-
if (
|
|
667
|
+
if (hasOpenExecution(task)) {
|
|
648
668
|
rollForward()
|
|
649
669
|
return []
|
|
650
670
|
}
|
|
@@ -736,6 +756,24 @@ export class HostTaskLedger {
|
|
|
736
756
|
this.commit()
|
|
737
757
|
}
|
|
738
758
|
|
|
759
|
+
allTasks(): readonly TaskRecord[] {
|
|
760
|
+
return this.document.tasks
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
getTask(id: string): TaskRecord | undefined {
|
|
764
|
+
return this.document.tasks.find(item => item.id === id)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
saveTaskRecord(task: TaskRecord): void {
|
|
768
|
+
const exists = this.document.tasks.some(item => item.id === task.id)
|
|
769
|
+
if (exists) {
|
|
770
|
+
this.document.tasks = this.document.tasks.map(item => item.id === task.id ? task : item)
|
|
771
|
+
} else {
|
|
772
|
+
this.document.tasks = [...this.document.tasks, task]
|
|
773
|
+
}
|
|
774
|
+
this.commit()
|
|
775
|
+
}
|
|
776
|
+
|
|
739
777
|
private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
|
|
740
778
|
const now = this.now()
|
|
741
779
|
let runs: OpenedRun[] | undefined
|
|
@@ -801,7 +839,7 @@ export class HostTaskLedger {
|
|
|
801
839
|
case 'delete': {
|
|
802
840
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
803
841
|
if (task === undefined) throw new Error('task not found')
|
|
804
|
-
if (
|
|
842
|
+
if (hasOpenExecution(task)) throw new Error('running task cannot be deleted')
|
|
805
843
|
// Subtasks keep their link: deleting the parent would leave dangling
|
|
806
844
|
// children, so the user detaches or deletes them explicitly first.
|
|
807
845
|
if (this.document.tasks.some(item => item.parentId === action.taskId)) {
|
|
@@ -820,7 +858,10 @@ export class HostTaskLedger {
|
|
|
820
858
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
821
859
|
if (task === undefined) throw new Error('task not found')
|
|
822
860
|
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
823
|
-
|
|
861
|
+
// The lock belongs to an open execution, not to the column: a card
|
|
862
|
+
// parked in 'running' by hand has no session and stays movable, while
|
|
863
|
+
// a card the runner is executing cannot be moved out from under it.
|
|
864
|
+
if (hasOpenExecution(task)) throw new Error('running task cannot be moved')
|
|
824
865
|
if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status')
|
|
825
866
|
this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item)
|
|
826
867
|
break
|
|
@@ -876,6 +917,18 @@ export class HostTaskLedger {
|
|
|
876
917
|
: item)
|
|
877
918
|
break
|
|
878
919
|
}
|
|
920
|
+
case 'rename-tag': {
|
|
921
|
+
const result = applyRenameTag(this.document.tasks, action.from, action.to, now)
|
|
922
|
+
if (result.error !== undefined) throw new Error(result.error)
|
|
923
|
+
if (result.changed) this.document.tasks = [...result.tasks]
|
|
924
|
+
break
|
|
925
|
+
}
|
|
926
|
+
case 'delete-tag': {
|
|
927
|
+
const result = applyDeleteTag(this.document.tasks, action.name, now)
|
|
928
|
+
if (result.error !== undefined) throw new Error(result.error)
|
|
929
|
+
if (result.changed) this.document.tasks = [...result.tasks]
|
|
930
|
+
break
|
|
931
|
+
}
|
|
879
932
|
case 'set-schedule': {
|
|
880
933
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
881
934
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
@@ -888,7 +941,7 @@ export class HostTaskLedger {
|
|
|
888
941
|
case 'run': {
|
|
889
942
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
890
943
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
891
|
-
if (task === undefined ||
|
|
944
|
+
if (task === undefined || hasOpenExecution(task)) throw new Error('task is already running or missing')
|
|
892
945
|
// The confirmation gate judges the RESOLVED binding: a subtask that
|
|
893
946
|
// inherits an elevated permission from its parent is exactly as
|
|
894
947
|
// unconfirmed as the parent would be without its own stamp.
|
|
@@ -909,10 +962,12 @@ export class HostTaskLedger {
|
|
|
909
962
|
*
|
|
910
963
|
* A plain cascade launches one session per participant, so every participant
|
|
911
964
|
* carries its own resolved binding and each one gates the run. A team run
|
|
912
|
-
* launches only the Lead session: the Lead's binding gates it,
|
|
913
|
-
*
|
|
914
|
-
* refused
|
|
915
|
-
*
|
|
965
|
+
* launches only the Lead session: the Lead's binding gates it, and a
|
|
966
|
+
* teammate inherits that session's permission, so a subtask's OWN pin is
|
|
967
|
+
* refused exactly when the Lead session does not already carry it — the
|
|
968
|
+
* teammate could never be given that authority, and dropping the pin
|
|
969
|
+
* silently would misreport the work (an inherited binding is the Lead's own
|
|
970
|
+
* and stays allowed once the Lead is confirmed).
|
|
916
971
|
* @param root - the task being run.
|
|
917
972
|
* @returns the first refusal, or undefined when the run may start.
|
|
918
973
|
*/
|
|
@@ -923,12 +978,17 @@ export class HostTaskLedger {
|
|
|
923
978
|
if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
|
|
924
979
|
return { kind: 'root', title: lead.title }
|
|
925
980
|
}
|
|
926
|
-
// The
|
|
927
|
-
//
|
|
981
|
+
// The Lead session runs at the Lead's own binding, or at the deployment
|
|
982
|
+
// default when it pins none; a teammate inherits that permission and
|
|
983
|
+
// cannot be narrowed below it, so it carries a subtask pin when it is
|
|
984
|
+
// already at least as wide. The raw record carries the subtask's OWN
|
|
985
|
+
// binding: the resolved participant above already folded an inherited one
|
|
986
|
+
// into its permission.
|
|
987
|
+
const leadPermission = effectivePermission(lead ?? root) ?? this.sessionDefaultPermission
|
|
928
988
|
const pinned = participants.find(participant => {
|
|
929
989
|
if (participant.id === root.id) return false
|
|
930
990
|
const raw = this.document.tasks.find(task => task.id === participant.id)
|
|
931
|
-
return raw !== undefined &&
|
|
991
|
+
return raw !== undefined && !permissionCarriedBy(leadPermission, effectivePermission(raw))
|
|
932
992
|
})
|
|
933
993
|
return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
|
|
934
994
|
}
|
|
@@ -947,14 +1007,13 @@ export class HostTaskLedger {
|
|
|
947
1007
|
return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
|
|
948
1008
|
.map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
|
|
949
1009
|
.filter(participant => participant.archivedAt === undefined
|
|
950
|
-
&& participant.status !== 'running'
|
|
951
1010
|
&& !hasOpenExecution(participant))
|
|
952
1011
|
}
|
|
953
1012
|
|
|
954
1013
|
/** Whether a task or any of its subtasks still has an open execution. */
|
|
955
1014
|
private subtreeHasOpenExecution(id: string): boolean {
|
|
956
1015
|
return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
|
|
957
|
-
.some(task =>
|
|
1016
|
+
.some(task => hasOpenExecution(task))
|
|
958
1017
|
}
|
|
959
1018
|
|
|
960
1019
|
/**
|
|
@@ -979,7 +1038,7 @@ export class HostTaskLedger {
|
|
|
979
1038
|
const runs: OpenedRun[] = []
|
|
980
1039
|
for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
|
|
981
1040
|
if (task.archivedAt !== undefined) continue
|
|
982
|
-
if (
|
|
1041
|
+
if (hasOpenExecution(task)) continue
|
|
983
1042
|
const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
|
|
984
1043
|
const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
|
|
985
1044
|
started.set(task.id, opened.task)
|
|
@@ -1147,7 +1206,6 @@ export class HostTaskLedger {
|
|
|
1147
1206
|
let changed = false
|
|
1148
1207
|
const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
|
|
1149
1208
|
this.document.tasks = this.document.tasks.map(task => {
|
|
1150
|
-
if (task.status !== 'running') return task
|
|
1151
1209
|
const execution = task.executions.at(-1)
|
|
1152
1210
|
if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
|
|
1153
1211
|
changed = true
|
|
@@ -1166,7 +1224,7 @@ export class HostTaskLedger {
|
|
|
1166
1224
|
}
|
|
1167
1225
|
|
|
1168
1226
|
/**
|
|
1169
|
-
* Field-preserving migration of
|
|
1227
|
+
* Field-preserving migration of an older document. It first proves every task
|
|
1170
1228
|
* row is structurally valid, so a document that would silently drop or coerce
|
|
1171
1229
|
* rows fails loudly instead (no quarantined-empty restart), and then reuses
|
|
1172
1230
|
* the current normalization.
|
|
@@ -1177,6 +1235,11 @@ export class HostTaskLedger {
|
|
|
1177
1235
|
* (the stored `nextRunAt` already encodes the old zone), but the rule stops
|
|
1178
1236
|
* following a later `TZ` change, which is what made an existing schedule
|
|
1179
1237
|
* silently move when the Host's zone changed.
|
|
1238
|
+
*
|
|
1239
|
+
* v5 adds the per-execution acceptance block. The migration deliberately does
|
|
1240
|
+
* NOT stamp one: an execution opened before v5 keeps no contract, so it
|
|
1241
|
+
* settles on its own historical verdict and no in-flight run is retroactively
|
|
1242
|
+
* judged by a gate that was never armed for it.
|
|
1180
1243
|
*/
|
|
1181
1244
|
private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
|
|
1182
1245
|
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
|
|
@@ -1194,6 +1257,54 @@ export class HostTaskLedger {
|
|
|
1194
1257
|
return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
|
|
1195
1258
|
}
|
|
1196
1259
|
|
|
1260
|
+
/**
|
|
1261
|
+
* Find the still-open execution that ran in one session, with its task.
|
|
1262
|
+
*
|
|
1263
|
+
* The completion gate keys on the session id its tool call arrives from: the
|
|
1264
|
+
* execution record is the only object binding a session to a card, and an
|
|
1265
|
+
* already-settled execution is deliberately not returned (a session the board
|
|
1266
|
+
* no longer owns must not be gated).
|
|
1267
|
+
* @param sessionId - the DSH session the tool call runs in.
|
|
1268
|
+
* @returns the open execution and its task, or undefined.
|
|
1269
|
+
*/
|
|
1270
|
+
findOpenExecutionBySession(sessionId: string): { task: TaskRecord; execution: ExecutionRecord } | undefined {
|
|
1271
|
+
if (sessionId === '') return undefined
|
|
1272
|
+
for (const task of this.document.tasks) {
|
|
1273
|
+
for (const execution of task.executions) {
|
|
1274
|
+
if (execution.endedAt !== undefined) continue
|
|
1275
|
+
if (execution.sessionId === sessionId) return { task, execution }
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return undefined
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Replace one execution's acceptance block (creating it when absent).
|
|
1283
|
+
*
|
|
1284
|
+
* A no-op when the serialized value is unchanged, so a coarse caller that
|
|
1285
|
+
* re-writes the same state never bumps the ledger revision. A settled
|
|
1286
|
+
* execution keeps its record: the report of a finished cycle must survive,
|
|
1287
|
+
* and only the board's own settlement moves the card.
|
|
1288
|
+
* @param taskId - the task owning the execution.
|
|
1289
|
+
* @param executionId - the execution to update.
|
|
1290
|
+
* @param verification - the new block.
|
|
1291
|
+
* @returns true when the ledger changed.
|
|
1292
|
+
*/
|
|
1293
|
+
setVerification(taskId: string, executionId: string, verification: ExecutionVerification): boolean {
|
|
1294
|
+
const now = this.now()
|
|
1295
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
1296
|
+
const execution = task?.executions.find(entry => entry.id === executionId)
|
|
1297
|
+
if (task === undefined || execution === undefined) return false
|
|
1298
|
+
if (JSON.stringify(execution.verification ?? null) === JSON.stringify(verification)) return false
|
|
1299
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
|
|
1300
|
+
...item,
|
|
1301
|
+
updatedAt: now,
|
|
1302
|
+
executions: item.executions.map(entry => entry.id === executionId ? { ...entry, verification } : entry),
|
|
1303
|
+
})
|
|
1304
|
+
this.commit()
|
|
1305
|
+
return true
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1197
1308
|
private load(dir: string): LedgerDocument {
|
|
1198
1309
|
const existed = existsSync(this.file)
|
|
1199
1310
|
// schemaVersion stays unknown-typed here: on-disk documents may be v2 or
|
|
@@ -1204,8 +1315,8 @@ export class HostTaskLedger {
|
|
|
1204
1315
|
} catch (error) {
|
|
1205
1316
|
return this.recoverCorrupt(dir, existed, error)
|
|
1206
1317
|
}
|
|
1207
|
-
if (parsed.schemaVersion ===
|
|
1208
|
-
|
|
1318
|
+
if (typeof parsed.schemaVersion === 'number'
|
|
1319
|
+
&& TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
|
|
1209
1320
|
try {
|
|
1210
1321
|
return this.migrateLegacyDocument(parsed)
|
|
1211
1322
|
} catch (error) {
|
|
@@ -1426,4 +1537,4 @@ export class HostTaskLedger {
|
|
|
1426
1537
|
}
|
|
1427
1538
|
throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
|
|
1428
1539
|
}
|
|
1429
|
-
}
|
|
1540
|
+
}
|
package/src/host-routes.ts
CHANGED
|
@@ -208,7 +208,7 @@ export function makeTaskBoardRoutes(
|
|
|
208
208
|
if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
|
|
209
209
|
return writeJson(res, 413, { ok: false, error: 'body-too-large' }, { 'cache-control': 'no-store' })
|
|
210
210
|
}
|
|
211
|
-
writeJson(res, 200, service.apply(parsed.requestId, parsed.action, parsed.initiator), { 'cache-control': 'no-store' })
|
|
211
|
+
writeJson(res, 200, await service.apply(parsed.requestId, parsed.action, parsed.initiator), { 'cache-control': 'no-store' })
|
|
212
212
|
} catch (error) {
|
|
213
213
|
const message = error instanceof Error ? error.message : String(error)
|
|
214
214
|
writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
|
|
@@ -289,5 +289,18 @@ export function makeTaskBoardRoutes(
|
|
|
289
289
|
}
|
|
290
290
|
},
|
|
291
291
|
}
|
|
292
|
-
|
|
292
|
+
const verification: WebRoute = {
|
|
293
|
+
kind: 'exact',
|
|
294
|
+
path: `${TASK_BOARD_API_PREFIX}/verification`,
|
|
295
|
+
handler: async (req, res): Promise<void> => {
|
|
296
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
|
|
297
|
+
if (!guard(req, res)) return
|
|
298
|
+
try {
|
|
299
|
+
writeJson(res, 200, await service.verificationOptions(), { 'cache-control': 'no-store' })
|
|
300
|
+
} catch (error) {
|
|
301
|
+
writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }, { 'cache-control': 'no-store' })
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
return [state, action, events, parse, verification]
|
|
293
306
|
}
|