@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
package/src/core/handover.ts
CHANGED
|
@@ -40,7 +40,11 @@ export const HANDOVER_MAX_TOTAL_BYTES = 8 * 1024
|
|
|
40
40
|
/** Per target-id (workspace/preset) byte cap. */
|
|
41
41
|
export const HANDOVER_MAX_TARGET_BYTES = 256
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Fail-safe baseline for the confirmation gate: what it falls back to when the
|
|
45
|
+
* deployment pins no baseline of its own and the Host's default permission
|
|
46
|
+
* preset cannot be read. Every elevation above it stays gated.
|
|
47
|
+
*/
|
|
44
48
|
export const DEFAULT_SESSION_PERMISSION: TaskPermission = 'read-only'
|
|
45
49
|
|
|
46
50
|
/** Permission elevation rank (higher = more authority). */
|
|
@@ -105,6 +109,17 @@ export function exceedsSessionDefault(permission: TaskPermission | undefined, se
|
|
|
105
109
|
return (PERMISSION_RANK.get(permission) ?? -1) > (PERMISSION_RANK.get(sessionDefault) ?? -1)
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Whether a session running at `holder` already carries `permission`: the
|
|
114
|
+
* permission is absent, or its rank does not exceed the holder's. A teammate
|
|
115
|
+
* inherits the Lead session's permission and cannot be narrowed below it, so a
|
|
116
|
+
* subtask's own pin is carried by a team run exactly when this holds, and a pin
|
|
117
|
+
* it does not carry would be silently dropped instead.
|
|
118
|
+
*/
|
|
119
|
+
export function permissionCarriedBy(holder: TaskPermission, permission: TaskPermission | undefined): boolean {
|
|
120
|
+
return !exceedsSessionDefault(permission, holder)
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
/**
|
|
109
124
|
* The confirmation-gate predicate: an elevated permission without a human
|
|
110
125
|
* confirmation stamp. Manual run/rerun and cron must refuse such a card.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue-form vocabulary the card excerpt recognizes.
|
|
3
|
+
*
|
|
4
|
+
* Issue forms from an issue tracker (and the zh templates this family's repositories use)
|
|
5
|
+
* write the same section headings and empty-field placeholders on every issue.
|
|
6
|
+
* The patterns are data about those forms, not UI copy, so they live in the
|
|
7
|
+
* shared core rather than in a locale dictionary; the browser half imports them.
|
|
8
|
+
*
|
|
9
|
+
* @module @linxin666/dsh-client-ui-task-board/core/issue-form
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Section headings whose body is the issue's own summary (zh and en forms). */
|
|
13
|
+
export const SUMMARY_HEADING = /^(?:摘要|概要|概述|简介|描述|问题描述|问题|现象|summary|description|overview|problem|what happened)$/i
|
|
14
|
+
|
|
15
|
+
/** Values an issue form writes for an empty field. */
|
|
16
|
+
export const EMPTY_FIELD = /^(?:_?no response_?|n\/a|none|无|暂无)$/i
|
|
17
|
+
|
|
18
|
+
/** A trailing colon (ASCII or full-width) after a heading. */
|
|
19
|
+
export const HEADING_COLON = /[:\uFF1A]\s*$/
|
package/src/core/store.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { isExecutionOutcome } from './subtask.ts'
|
|
|
15
15
|
import type { TaskHandover } from './handover.ts'
|
|
16
16
|
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
17
17
|
import { sanitizeHandover } from './handover.ts'
|
|
18
|
+
import { normalizeTaskIntegrations } from './extension.ts'
|
|
19
|
+
import { normalizeVerification } from './verification.ts'
|
|
18
20
|
|
|
19
21
|
/** Persistence seam for the task ledger. */
|
|
20
22
|
export interface TaskStore {
|
|
@@ -67,8 +69,11 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
67
69
|
if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
|
|
68
70
|
if (record.mode !== undefined && typeof record.mode !== 'string') return false
|
|
69
71
|
if (record.permission !== undefined && typeof record.permission !== 'string') return false
|
|
72
|
+
if (record.integrations !== undefined && (typeof record.integrations !== 'object' || record.integrations === null || Array.isArray(record.integrations))) return false
|
|
73
|
+
if (record.hidden !== undefined && typeof record.hidden !== 'boolean') return false
|
|
70
74
|
if (record.reuseSession !== undefined && typeof record.reuseSession !== 'boolean') return false
|
|
71
75
|
if (record.goalRun !== undefined && typeof record.goalRun !== 'boolean') return false
|
|
76
|
+
if (record.skipVerification !== undefined && typeof record.skipVerification !== 'boolean') return false
|
|
72
77
|
if (!Array.isArray(record.executions)) return false
|
|
73
78
|
for (const execution of record.executions) {
|
|
74
79
|
if (typeof execution !== 'object' || execution === null) return false
|
|
@@ -82,6 +87,9 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
82
87
|
if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
|
|
83
88
|
if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
|
|
84
89
|
if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
|
|
90
|
+
// The acceptance block is repaired like every other optional field: an
|
|
91
|
+
// unusable one is dropped, which can only make the board MORE strict.
|
|
92
|
+
if (entry.verification !== undefined && normalizeVerification(entry.verification) === undefined) return false
|
|
85
93
|
}
|
|
86
94
|
return true
|
|
87
95
|
}
|
|
@@ -199,6 +207,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
199
207
|
runGroupId: normalizeTargetId(execution.runGroupId),
|
|
200
208
|
ownResult: isExecutionOutcome(execution.ownResult) ? execution.ownResult : undefined,
|
|
201
209
|
ownError: typeof execution.ownError === 'string' ? execution.ownError : undefined,
|
|
210
|
+
// A malformed acceptance block is dropped rather than dropping the
|
|
211
|
+
// execution record: the board then treats that run as unverified.
|
|
212
|
+
verification: normalizeVerification(execution.verification),
|
|
202
213
|
}))
|
|
203
214
|
// Execution targets are normalized like the schedule: blank strings
|
|
204
215
|
// clear the pin and unknown permission strings from a future version
|
|
@@ -211,6 +222,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
211
222
|
// The goal opt-in is stored only when it is OFF: absent means the default
|
|
212
223
|
// (start each run with /goal), so a hand-edited true normalizes back to it.
|
|
213
224
|
task.goalRun = row.goalRun === false ? false : undefined
|
|
225
|
+
// The acceptance opt-out is stored only when it is ON: absent inherits the
|
|
226
|
+
// board-wide switch, so a hand-edited false normalizes back to inherit.
|
|
227
|
+
task.skipVerification = row.skipVerification === true ? true : undefined
|
|
214
228
|
task.freeze = normalizeFreeze(row.freeze)
|
|
215
229
|
task.handover = normalizeHandover(row.handover)
|
|
216
230
|
// Tags are repaired field by field like the schedule: a malformed entry is
|
|
@@ -218,6 +232,8 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
218
232
|
// dropping the task row.
|
|
219
233
|
task.tags = normalizeTags(row.tags)
|
|
220
234
|
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
|
|
235
|
+
task.integrations = normalizeTaskIntegrations(row.integrations)
|
|
236
|
+
task.hidden = row.hidden === true ? true : undefined
|
|
221
237
|
tasks.push(task)
|
|
222
238
|
}
|
|
223
239
|
return tasks
|
package/src/core/tasks.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* unit-testable in isolation.
|
|
6
6
|
*/
|
|
7
7
|
import type { FreezeSnapshot } from './freeze-snapshot.ts'
|
|
8
|
+
import type { ExecutionVerification } from './verification.ts'
|
|
8
9
|
import type { TaskHandover, TaskHandoverInput } from './handover.ts'
|
|
10
|
+
import { normalizeTaskIntegrations, type TaskBoardExtensionPayload } from './extension.ts'
|
|
9
11
|
|
|
10
12
|
/** Task lifecycle status, one per kanban column. */
|
|
11
13
|
export type TaskStatus = 'backlog' | 'todo' | 'running' | 'done' | 'failed'
|
|
@@ -55,6 +57,14 @@ export interface ExecutionRecord {
|
|
|
55
57
|
ownResult?: ExecutionOutcome
|
|
56
58
|
/** Human failure text that arrived with {@link ownResult}. */
|
|
57
59
|
ownError?: string
|
|
60
|
+
/**
|
|
61
|
+
* Goal acceptance state of this execution (task-board goal verification).
|
|
62
|
+
* Fixed when the execution starts and never rewritten from later settings:
|
|
63
|
+
* the contract is frozen here, the attempts accrue here, and the settlement
|
|
64
|
+
* rule reads the pass record here. Absent on an execution that started
|
|
65
|
+
* before the feature existed, which keeps it on the historical verdict.
|
|
66
|
+
*/
|
|
67
|
+
verification?: ExecutionVerification
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/**
|
|
@@ -295,6 +305,20 @@ export interface TaskRecord {
|
|
|
295
305
|
* turn. A session whose `/goal` command is refused still runs the prompt.
|
|
296
306
|
*/
|
|
297
307
|
goalRun?: boolean
|
|
308
|
+
/**
|
|
309
|
+
* Run this task's goal executions WITHOUT the board's acceptance gate.
|
|
310
|
+
*
|
|
311
|
+
* Absent means INHERIT: the execution's applicability is decided by the
|
|
312
|
+
* board-wide `goalVerification` switch frozen at start, exactly as a card
|
|
313
|
+
* that never touched this option behaves. Only an explicit `true` opts the
|
|
314
|
+
* card out, and the execution records `applicability: 'disabled'` so the
|
|
315
|
+
* report says plainly that nothing certified this run.
|
|
316
|
+
*
|
|
317
|
+
* The opt-out is per execution, not per run: flipping it never rewrites the
|
|
318
|
+
* verdict rule of an execution already open, and a rerun or a scheduled
|
|
319
|
+
* occurrence reads the card's current value when it freezes its contract.
|
|
320
|
+
*/
|
|
321
|
+
skipVerification?: boolean
|
|
298
322
|
/**
|
|
299
323
|
* Frozen context snapshot for a continuation card; absent on plain tasks.
|
|
300
324
|
* Sanitized before it enters the ledger (redaction, slash-command taint,
|
|
@@ -326,6 +350,20 @@ export interface TaskRecord {
|
|
|
326
350
|
* absent means on-board.
|
|
327
351
|
*/
|
|
328
352
|
archivedAt?: number
|
|
353
|
+
/**
|
|
354
|
+
* Opaque external-provider metadata, keyed by extension id; optional and
|
|
355
|
+
* additive so existing tasks require no ledger migration. The board stores
|
|
356
|
+
* and forwards it without interpreting the keys — each provider owns the
|
|
357
|
+
* shape and validation of its own entry.
|
|
358
|
+
*/
|
|
359
|
+
integrations?: Record<string, TaskBoardExtensionPayload>
|
|
360
|
+
/**
|
|
361
|
+
* Whether the card is held off the board: it exists in the ledger (and its
|
|
362
|
+
* execution history is intact) but no column ever renders it. Providers
|
|
363
|
+
* create hidden cards for work they materialize but do not want displayed;
|
|
364
|
+
* absent means on-board.
|
|
365
|
+
*/
|
|
366
|
+
hidden?: boolean
|
|
329
367
|
}
|
|
330
368
|
|
|
331
369
|
/**
|
|
@@ -359,7 +397,7 @@ export interface NewTaskInput {
|
|
|
359
397
|
* inherits the parent's unset execution targets.
|
|
360
398
|
*/
|
|
361
399
|
parentId?: string
|
|
362
|
-
/** Workspace the execution must run in; empty/absent = the recent workspace. */
|
|
400
|
+
/** Workspace the execution must run in; empty/absent = the creating session's workspace for a root task, else the recent workspace at execution time. */
|
|
363
401
|
workspaceId?: string
|
|
364
402
|
/** Agent preset the execution session must be composed from; empty/absent = deployment default. */
|
|
365
403
|
mode?: string
|
|
@@ -376,6 +414,11 @@ export interface NewTaskInput {
|
|
|
376
414
|
* default (goal run); an explicit false requests a single plain turn.
|
|
377
415
|
*/
|
|
378
416
|
goalRun?: boolean
|
|
417
|
+
/**
|
|
418
|
+
* Run this task's goal executions without the acceptance gate; absent
|
|
419
|
+
* inherits the board-wide `goalVerification` switch.
|
|
420
|
+
*/
|
|
421
|
+
skipVerification?: boolean
|
|
379
422
|
/**
|
|
380
423
|
* Optional scheduled-run rule requested at creation time (the new-task
|
|
381
424
|
* dialog): an enable flag, a 5-field cron expression, and the IANA zone its
|
|
@@ -398,6 +441,12 @@ export interface NewTaskInput {
|
|
|
398
441
|
* ahead of the execution prompt; a bare name changes nothing at run time.
|
|
399
442
|
*/
|
|
400
443
|
tags?: TaskTag[]
|
|
444
|
+
/** Opaque external-provider metadata keyed by extension id. */
|
|
445
|
+
integrations?: Record<string, TaskBoardExtensionPayload>
|
|
446
|
+
/** Column the created card opens in; absent means the board default (todo). */
|
|
447
|
+
status?: TaskStatus
|
|
448
|
+
/** Create the card off-board (held in the ledger, never rendered). */
|
|
449
|
+
hidden?: boolean
|
|
401
450
|
}
|
|
402
451
|
|
|
403
452
|
/** The five kanban columns, in display order. */
|
|
@@ -409,8 +458,20 @@ export const COLUMNS: readonly { status: TaskStatus; label: string }[] = [
|
|
|
409
458
|
{ status: 'failed', label: '已失败' },
|
|
410
459
|
]
|
|
411
460
|
|
|
412
|
-
/**
|
|
413
|
-
|
|
461
|
+
/**
|
|
462
|
+
* Statuses a card may be moved to by hand: every column. The board UI and the
|
|
463
|
+
* agent tool surface share this one list, so no column can be manual on one
|
|
464
|
+
* surface and locked on the other.
|
|
465
|
+
*
|
|
466
|
+
* The column is a board statement, not a claim about a run. A hand-written
|
|
467
|
+
* `done`/`failed` declares that the work finished (or failed) outside a
|
|
468
|
+
* Host-run execution — human hands, an external system — and a hand-written
|
|
469
|
+
* `running` says the work is under way without a tracked session. None of them
|
|
470
|
+
* creates an {@link ExecutionRecord}, which is what keeps a declaration
|
|
471
|
+
* distinguishable from a recorded outcome in the card's execution history; the
|
|
472
|
+
* next settled run overwrites the column with the real one.
|
|
473
|
+
*/
|
|
474
|
+
export const MANUAL_STATUSES: readonly TaskStatus[] = ['backlog', 'todo', 'running', 'done', 'failed']
|
|
414
475
|
|
|
415
476
|
/** Statuses the runner may move a card to from 'running'. */
|
|
416
477
|
export const RUNNER_SETTLE_STATUSES: readonly TaskStatus[] = ['done', 'failed']
|
|
@@ -425,9 +486,35 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
|
|
|
425
486
|
return typeof value === 'string' && (ALL_STATUSES as readonly string[]).includes(value)
|
|
426
487
|
}
|
|
427
488
|
|
|
428
|
-
/**
|
|
489
|
+
/**
|
|
490
|
+
* Whether the manual move `from` -> `to` is a legal *column* change: any
|
|
491
|
+
* column, and never the one the card already shows.
|
|
492
|
+
*
|
|
493
|
+
* Whether the card may be moved at all is a separate question. The lock an
|
|
494
|
+
* executing card carries belongs to its open execution, never to the column
|
|
495
|
+
* text, so it is {@link canMoveTask} — not this predicate — that expresses it.
|
|
496
|
+
*/
|
|
429
497
|
export function canMoveManually(from: TaskStatus, to: TaskStatus): boolean {
|
|
430
|
-
return from !==
|
|
498
|
+
return from !== to && (MANUAL_STATUSES as readonly TaskStatus[]).includes(to)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Whether the board may move `task` to `to` by hand: the card is on-board,
|
|
503
|
+
* holds no open execution, and the target is a legal column change. The ledger,
|
|
504
|
+
* the detail view's status buttons and the board's drop handler all decide
|
|
505
|
+
* through this one predicate, so no two surfaces can disagree about a card.
|
|
506
|
+
*/
|
|
507
|
+
export function canMoveTask(task: TaskRecord, to: TaskStatus): boolean {
|
|
508
|
+
return task.archivedAt === undefined && !hasOpenExecution(task) && canMoveManually(task.status, to)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Whether the task still has an execution the runner has not settled. This —
|
|
513
|
+
* not the `running` column — is what "the runner owns this card" means: a card
|
|
514
|
+
* parked in `running` by hand has no session and stays fully movable.
|
|
515
|
+
*/
|
|
516
|
+
export function hasOpenExecution(task: TaskRecord): boolean {
|
|
517
|
+
return task.executions.some(execution => execution.endedAt === undefined)
|
|
431
518
|
}
|
|
432
519
|
|
|
433
520
|
/** Normalize one optional execution-target string: trim; blank collapses to undefined. */
|
|
@@ -457,13 +544,14 @@ export function freezeOf(
|
|
|
457
544
|
/** Create a task from user input. */
|
|
458
545
|
export function createTask(input: NewTaskInput, now: number, id: string): TaskRecord {
|
|
459
546
|
const tags = normalizeTags(input.tags)
|
|
547
|
+
const integrations = normalizeTaskIntegrations(input.integrations)
|
|
460
548
|
return {
|
|
461
549
|
id,
|
|
462
550
|
title: input.title.trim(),
|
|
463
551
|
description: input.description.trim(),
|
|
464
552
|
prompt: input.prompt.trim(),
|
|
465
553
|
parentId: normalizeTargetId(input.parentId),
|
|
466
|
-
status: 'todo',
|
|
554
|
+
status: input.status ?? 'todo',
|
|
467
555
|
createdAt: now,
|
|
468
556
|
updatedAt: now,
|
|
469
557
|
executions: [],
|
|
@@ -477,9 +565,15 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
477
565
|
// touched the option (and every card written before the field existed)
|
|
478
566
|
// keeps starting its runs with /goal.
|
|
479
567
|
goalRun: input.goalRun === false ? false : undefined,
|
|
568
|
+
// Default OFF: only an explicit true is stored, so a card that never
|
|
569
|
+
// touched the option inherits the board-wide acceptance switch exactly as
|
|
570
|
+
// every card written before the field existed does.
|
|
571
|
+
skipVerification: input.skipVerification === true ? true : undefined,
|
|
480
572
|
...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
|
|
481
573
|
...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
|
|
482
574
|
...(tags === undefined ? {} : { tags }),
|
|
575
|
+
...(integrations === undefined ? {} : { integrations }),
|
|
576
|
+
...(input.hidden === true ? { hidden: true } : {}),
|
|
483
577
|
}
|
|
484
578
|
}
|
|
485
579
|
|
|
@@ -49,8 +49,9 @@ export function applySetParent(
|
|
|
49
49
|
if (task.archivedAt !== undefined) return { tasks, applied: false, error: 'archived task is read-only' }
|
|
50
50
|
// A task with an open execution may be a participant of a running cascade.
|
|
51
51
|
// Moving it would break that group's parent walk and leave the parent card
|
|
52
|
-
// in the running column forever, so the link is frozen until it settles.
|
|
53
|
-
|
|
52
|
+
// in the running column forever, so the link is frozen until it settles. The
|
|
53
|
+
// lock is the open execution: a card parked in 'running' by hand links freely.
|
|
54
|
+
if (task.executions.some(execution => execution.endedAt === undefined)) {
|
|
54
55
|
return { tasks, applied: false, error: 'running task cannot be re-parented' }
|
|
55
56
|
}
|
|
56
57
|
const check = checkParentLink(tasks, id, parentId, normalizeSubtaskDepth(maxSubtaskDepth))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Label-management use cases (issue: labels could not be cleaned up).
|
|
3
|
+
*
|
|
4
|
+
* A label lives on every task that carries it, so renaming or removing one is a
|
|
5
|
+
* ledger-wide edit, not a task edit: the board's label row is the union of the
|
|
6
|
+
* labels in use, and without these two transitions a label created by an
|
|
7
|
+
* afternoon experiment stayed on the board forever.
|
|
8
|
+
*
|
|
9
|
+
* Both transitions are pure ledger rewrites (no persistence, no notify — the
|
|
10
|
+
* controller and the Host ledger orchestrate those), and both are idempotent:
|
|
11
|
+
* a label that no task carries changes nothing and reports no error for the
|
|
12
|
+
* delete path (the desired end state already holds), while a rename from an
|
|
13
|
+
* unknown label is refused so a stale tab cannot silently do nothing.
|
|
14
|
+
*
|
|
15
|
+
* Renaming onto a name that is already in use MERGES the two: a task carrying
|
|
16
|
+
* both keeps the row it already had (with its execution hint) and loses the
|
|
17
|
+
* duplicate, which is the only reading that cannot lose a hint the user typed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { TAG_NAME_MAX_LENGTH, collectKnownTags, normalizeTags, type TaskRecord, type TaskTag } from '../tasks.ts'
|
|
21
|
+
|
|
22
|
+
/** Outcome of one ledger-wide label edit. */
|
|
23
|
+
export interface TagEditResult {
|
|
24
|
+
/** The ledger after the edit, or the input unchanged when nothing applied. */
|
|
25
|
+
tasks: readonly TaskRecord[]
|
|
26
|
+
/** Whether any task actually changed (drives whether the caller persists). */
|
|
27
|
+
changed: boolean
|
|
28
|
+
/** Refusal reason; the ledger is unchanged when set. */
|
|
29
|
+
error?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Trim a label name the way creation does. */
|
|
33
|
+
function normalizeName(value: string): string {
|
|
34
|
+
return value.trim()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether any task in the ledger carries this label. */
|
|
38
|
+
function carries(tasks: readonly TaskRecord[], name: string): boolean {
|
|
39
|
+
return collectKnownTags(tasks).some(tag => tag.name === name)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Rename one label everywhere, merging into an existing label of the target
|
|
44
|
+
* name. Every touched task gets a fresh updatedAt; untouched tasks keep theirs.
|
|
45
|
+
* @param tasks - current ledger.
|
|
46
|
+
* @param from - the label to rename.
|
|
47
|
+
* @param to - the new name (an existing name merges the two labels).
|
|
48
|
+
* @param now - clock instant (ms epoch).
|
|
49
|
+
* @returns the rewritten ledger, or a refusal.
|
|
50
|
+
*/
|
|
51
|
+
export function applyRenameTag(
|
|
52
|
+
tasks: readonly TaskRecord[],
|
|
53
|
+
from: string,
|
|
54
|
+
to: string,
|
|
55
|
+
now: number,
|
|
56
|
+
): TagEditResult {
|
|
57
|
+
const source = normalizeName(from)
|
|
58
|
+
const target = normalizeName(to)
|
|
59
|
+
if (source === '') return { tasks, changed: false, error: 'a label name is required' }
|
|
60
|
+
if (target === '') return { tasks, changed: false, error: 'a label name is required' }
|
|
61
|
+
if (target.length > TAG_NAME_MAX_LENGTH) return { tasks, changed: false, error: 'label name is too long' }
|
|
62
|
+
if (source === target) return { tasks, changed: false }
|
|
63
|
+
if (!carries(tasks, source)) return { tasks, changed: false, error: 'label not found' }
|
|
64
|
+
let changed = false
|
|
65
|
+
const next = tasks.map(task => {
|
|
66
|
+
const tags = task.tags
|
|
67
|
+
if (tags === undefined || !tags.some(tag => tag.name === source)) return task
|
|
68
|
+
changed = true
|
|
69
|
+
// A task that already carried BOTH names keeps the target's own row — the
|
|
70
|
+
// target is the label that survives, so its execution hint stays the one
|
|
71
|
+
// that applies. A task carrying only the source keeps that row's hint under
|
|
72
|
+
// the new name.
|
|
73
|
+
const hasTarget = tags.some(tag => tag.name === target)
|
|
74
|
+
const merged: TaskTag[] = []
|
|
75
|
+
for (const tag of tags) {
|
|
76
|
+
if (hasTarget && tag.name === source) continue
|
|
77
|
+
const name = tag.name === source ? target : tag.name
|
|
78
|
+
if (merged.some(entry => entry.name === name)) continue
|
|
79
|
+
merged.push(name === tag.name ? tag : { ...tag, name })
|
|
80
|
+
}
|
|
81
|
+
return { ...task, tags: normalizeTags(merged), updatedAt: now }
|
|
82
|
+
})
|
|
83
|
+
return { tasks: changed ? next : tasks, changed }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove one label from every task that carries it. Archived cards are included:
|
|
88
|
+
* the board's label row draws from the whole ledger, so a label left on an
|
|
89
|
+
* archived card would come straight back into the filter.
|
|
90
|
+
* @param tasks - current ledger.
|
|
91
|
+
* @param name - the label to remove.
|
|
92
|
+
* @param now - clock instant (ms epoch).
|
|
93
|
+
* @returns the rewritten ledger.
|
|
94
|
+
*/
|
|
95
|
+
export function applyDeleteTag(
|
|
96
|
+
tasks: readonly TaskRecord[],
|
|
97
|
+
name: string,
|
|
98
|
+
now: number,
|
|
99
|
+
): TagEditResult {
|
|
100
|
+
const target = normalizeName(name)
|
|
101
|
+
if (target === '') return { tasks, changed: false, error: 'a label name is required' }
|
|
102
|
+
let changed = false
|
|
103
|
+
const next = tasks.map(task => {
|
|
104
|
+
const tags = task.tags
|
|
105
|
+
if (tags === undefined || !tags.some(tag => tag.name === target)) return task
|
|
106
|
+
changed = true
|
|
107
|
+
return { ...task, tags: normalizeTags(tags.filter(tag => tag.name !== target)), updatedAt: now }
|
|
108
|
+
})
|
|
109
|
+
return { tasks: changed ? next : tasks, changed }
|
|
110
|
+
}
|
|
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
|
|
|
16
16
|
* Editable fields on a task (the update patch surface). `freeze` replaces the
|
|
17
17
|
* continuation-card snapshot (restamping frozenAt); an explicit null clears it.
|
|
18
18
|
*/
|
|
19
|
-
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun'>> & {
|
|
19
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun' | 'skipVerification'>> & {
|
|
20
20
|
freeze?: FreezeSnapshot & { redacted?: boolean } | null
|
|
21
21
|
/** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
|
|
22
22
|
handover?: TaskHandoverInput | null
|
|
@@ -43,11 +43,13 @@ export function hasContentPatch(patch: TaskUpdatePatch): boolean {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Whether a task's content may still be edited: the task must be on-board
|
|
46
|
-
* (not archived) and must never have started executing. Fail-closed: a
|
|
47
|
-
* running, settled, or cancelled
|
|
46
|
+
* (not archived) and must never have started executing. Fail-closed: a task
|
|
47
|
+
* with any execution record — running, settled, or cancelled before launch —
|
|
48
|
+
* keeps its content fixed. The run, not the column, is the trigger: a card
|
|
49
|
+
* parked in 'running' by hand has nothing recorded yet and stays editable.
|
|
48
50
|
*/
|
|
49
51
|
export function canEditTaskContent(task: TaskRecord): boolean {
|
|
50
|
-
return task.archivedAt === undefined && task.
|
|
52
|
+
return task.archivedAt === undefined && task.executions.length === 0
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Keep an unknown permission string from entering the ledger. */
|
|
@@ -111,6 +113,10 @@ export function applyUpdateTask(
|
|
|
111
113
|
// The /goal opt-in is default-ON: true (or an explicit null) returns the
|
|
112
114
|
// card to the default and stores nothing, only false pins a plain turn.
|
|
113
115
|
if ('goalRun' in patch) next.goalRun = patch.goalRun === false ? false : undefined
|
|
116
|
+
// The acceptance opt-out is the mirror image: default OFF, so only true
|
|
117
|
+
// pins a card out of the gate; false (or an explicit null) returns it to
|
|
118
|
+
// inheriting the board-wide switch and stores nothing.
|
|
119
|
+
if ('skipVerification' in patch) next.skipVerification = patch.skipVerification === true ? true : undefined
|
|
114
120
|
if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
|
|
115
121
|
if (mode !== undefined || 'mode' in patch) next.mode = mode
|
|
116
122
|
if (permission !== undefined || 'permission' in patch) next.permission = permission
|