dsh-taskboard 0.2.2 → 0.4.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/README.md +62 -6
- package/lib/client.js +1839 -74
- package/lib/host/execution.js +199 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +327 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +435 -5
- package/lib/host/routes.js.map +1 -1
- package/lib/host/store.js +12 -0
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +166 -0
- package/lib/host/templates.js.map +1 -0
- package/lib/host/tools.js +217 -3
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +23 -3
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +286 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +47 -3
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +118 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +360 -4
- package/src/client/board/TaskFormModal.tsx +193 -12
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +238 -11
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +198 -0
- package/src/host/execution.ts +301 -67
- package/src/host/git.ts +370 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +483 -5
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +215 -2
- package/src/index.ts +30 -1
- package/src/shared/api.ts +89 -3
- package/src/shared/protocol.ts +408 -0
- package/src/shared/version.ts +1 -1
package/src/shared/protocol.ts
CHANGED
|
@@ -102,6 +102,29 @@ export const URGENCY_COLOR: Readonly<Record<Urgency, string>> = {
|
|
|
102
102
|
// Execution
|
|
103
103
|
// ---------------------------------------------------------------------------
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Per-task code isolation mode (0.3.0).
|
|
107
|
+
* - `worktree`: each execution runs in a fresh `git worktree` on a dedicated
|
|
108
|
+
* task branch (`task/<标题>+<taskId>`) under `<workspace>/.dsh-worktrees/`.
|
|
109
|
+
* - `none`: run in the workspace directory as before, zero git interaction.
|
|
110
|
+
* Omitted = the default `worktree`; non-git projects auto-degrade at run
|
|
111
|
+
* time (the execution record carries an `isolationNote` explaining why).
|
|
112
|
+
*/
|
|
113
|
+
export type IsolationMode = 'worktree' | 'none'
|
|
114
|
+
|
|
115
|
+
/** Validate an isolation value. */
|
|
116
|
+
export function asIsolation(raw: string): IsolationMode {
|
|
117
|
+
if (raw !== 'worktree' && raw !== 'none') {
|
|
118
|
+
throw new Error("isolation must be 'worktree' or 'none'")
|
|
119
|
+
}
|
|
120
|
+
return raw
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Resolve a task's effective isolation (omitted → the worktree default). */
|
|
124
|
+
export function effectiveIsolation(task: Pick<TaskRecord, 'isolation'>): IsolationMode {
|
|
125
|
+
return task.isolation === undefined ? 'worktree' : task.isolation
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
/** How a task may run. */
|
|
106
129
|
export type ExecutionMode = 'claim' | 'scheduled'
|
|
107
130
|
|
|
@@ -237,6 +260,41 @@ export type CommentRecord = {
|
|
|
237
260
|
threadId?: string
|
|
238
261
|
}
|
|
239
262
|
|
|
263
|
+
/** One commit produced by an isolated execution (hash + subject). */
|
|
264
|
+
export type CommitInfo = { hash: string; subject: string }
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The structured execution report an agent submits at handoff (0.4.0).
|
|
268
|
+
* Commits/dirty/diff facts are host-collected git evidence — the report
|
|
269
|
+
* covers the BUSINESS side the host cannot see.
|
|
270
|
+
*/
|
|
271
|
+
export type ExecutionReport = {
|
|
272
|
+
/** What was done (1..2000 chars, required). */
|
|
273
|
+
summary: string
|
|
274
|
+
/** Files the agent changed (paths, ≤50 × 300 chars). */
|
|
275
|
+
changedFiles: string[]
|
|
276
|
+
/** How the work was self-verified (≤50 × 300 chars). */
|
|
277
|
+
checks: string[]
|
|
278
|
+
/** Produced artifacts worth reviewing (≤30 × 300 chars). */
|
|
279
|
+
artifacts: string[]
|
|
280
|
+
/** Known remaining risks / follow-ups (≤2000 chars, '' allowed). */
|
|
281
|
+
risk: string
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One Definition-of-Done checklist item (0.4.0). */
|
|
285
|
+
export type ChecklistItem = {
|
|
286
|
+
id: string
|
|
287
|
+
/** What must be true for acceptance (1..200 chars). */
|
|
288
|
+
text: string
|
|
289
|
+
checked: boolean
|
|
290
|
+
/** Who checked it: an agent session id, or 'user' for GUI toggles. */
|
|
291
|
+
checkedBy?: string
|
|
292
|
+
/** When it was checked (epoch ms). */
|
|
293
|
+
checkedAt?: number
|
|
294
|
+
/** Evidence note attached when checking (≤400 chars). */
|
|
295
|
+
note?: string
|
|
296
|
+
}
|
|
297
|
+
|
|
240
298
|
/** One execution attempt of a task. */
|
|
241
299
|
export type ExecutionRecord = {
|
|
242
300
|
id: string
|
|
@@ -248,6 +306,32 @@ export type ExecutionRecord = {
|
|
|
248
306
|
endedAt?: number
|
|
249
307
|
outcome: 'running' | 'succeeded' | 'failed' | 'cancelled'
|
|
250
308
|
error?: string
|
|
309
|
+
/** Code isolation actually used (`none` also covers degraded worktree runs). */
|
|
310
|
+
isolation?: IsolationMode
|
|
311
|
+
/** Why worktree isolation degraded to running in the original directory. */
|
|
312
|
+
isolationNote?: string
|
|
313
|
+
/** The task branch this execution worked on (worktree runs only). */
|
|
314
|
+
branch?: string
|
|
315
|
+
/** Absolute path of the dedicated worktree (worktree runs only). */
|
|
316
|
+
worktreePath?: string
|
|
317
|
+
/** HEAD of the task branch before the execution started. */
|
|
318
|
+
baseCommit?: string
|
|
319
|
+
/** HEAD at settlement. */
|
|
320
|
+
headCommit?: string
|
|
321
|
+
/** Commits between baseCommit and headCommit (hash + subject; capped at 50, newest first). */
|
|
322
|
+
commits?: CommitInfo[]
|
|
323
|
+
/** Total commits before the evidence cap (equals commits.length when under it). */
|
|
324
|
+
commitsTotal?: number
|
|
325
|
+
/** Uncommitted changes present at settlement (`git status --porcelain` lines; capped at 100). */
|
|
326
|
+
dirtyFiles?: string[]
|
|
327
|
+
/** Total uncommitted lines before the evidence cap. */
|
|
328
|
+
dirtyFilesTotal?: number
|
|
329
|
+
/** Aggregate diff stat between baseCommit and headCommit. */
|
|
330
|
+
diffStat?: string
|
|
331
|
+
/** How many files differ between baseCommit and headCommit. */
|
|
332
|
+
changedFiles?: number
|
|
333
|
+
/** The agent's structured report, submitted via taskboard_execution_report. */
|
|
334
|
+
report?: ExecutionReport
|
|
251
335
|
}
|
|
252
336
|
|
|
253
337
|
/** The per-model override a task may carry; absent = session default model. */
|
|
@@ -271,6 +355,26 @@ export type TaskRecord = {
|
|
|
271
355
|
blocked: boolean
|
|
272
356
|
execution: ExecutionConfig
|
|
273
357
|
model?: TaskModel
|
|
358
|
+
/** Code isolation for executions (omitted = the worktree default; see {@link IsolationMode}). */
|
|
359
|
+
isolation?: IsolationMode
|
|
360
|
+
/**
|
|
361
|
+
* The agent preset execution sessions are composed from (omitted = the
|
|
362
|
+
* deployment default preset). Recorded on the session header and mounted
|
|
363
|
+
* via the presets service at creation — this is what hands the session its
|
|
364
|
+
* tool set. Editable any time (each run composes fresh).
|
|
365
|
+
*/
|
|
366
|
+
presetId?: string
|
|
367
|
+
/**
|
|
368
|
+
* Definition-of-Done acceptance checklist (0.4.0). Agents may append items
|
|
369
|
+
* and check/uncheck them (with evidence); the GUI may edit the whole list.
|
|
370
|
+
* Unchecked items highlight at review time; done stays user-only.
|
|
371
|
+
*/
|
|
372
|
+
checklist?: ChecklistItem[]
|
|
373
|
+
/**
|
|
374
|
+
* The task branch fixed at the FIRST worktree creation (`task/<标题>+<taskId>`).
|
|
375
|
+
* Renaming the task afterwards never changes it (history preservation).
|
|
376
|
+
*/
|
|
377
|
+
branch?: string
|
|
274
378
|
/**
|
|
275
379
|
* The session currently holding the in-progress claim (explicit claim or a
|
|
276
380
|
* live execution). Present only while `status === 'in_progress'`: any move
|
|
@@ -344,6 +448,11 @@ export function newCommentId(): string {
|
|
|
344
448
|
return `c-${Date.now().toString(36)}-${suffix()}`
|
|
345
449
|
}
|
|
346
450
|
|
|
451
|
+
/** Mint a checklist item id. */
|
|
452
|
+
export function newChecklistItemId(): string {
|
|
453
|
+
return `k-${Date.now().toString(36)}-${suffix()}`
|
|
454
|
+
}
|
|
455
|
+
|
|
347
456
|
/** Mint an execution id. */
|
|
348
457
|
export function newExecutionId(): string {
|
|
349
458
|
return `e-${Date.now().toString(36)}-${suffix()}`
|
|
@@ -498,6 +607,301 @@ export function normalizeModel(raw: unknown): TaskModel {
|
|
|
498
607
|
return { provider: p, model: m }
|
|
499
608
|
}
|
|
500
609
|
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
// checklist + report validation (0.4.0)
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
/** Checklist size cap per task. */
|
|
615
|
+
export const MAX_CHECKLIST_ITEMS = 30
|
|
616
|
+
|
|
617
|
+
/** Checklist item text cap (chars). */
|
|
618
|
+
export const MAX_CHECKLIST_TEXT = 200
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Validate and normalize one checklist text line: trimmed, 1..200 chars.
|
|
622
|
+
* @param raw - the raw text.
|
|
623
|
+
* @throws when empty or too long.
|
|
624
|
+
*/
|
|
625
|
+
export function normalizeChecklistText(raw: string): string {
|
|
626
|
+
const t = raw.trim()
|
|
627
|
+
if (t.length === 0 || t.length > MAX_CHECKLIST_TEXT) {
|
|
628
|
+
throw new Error(`checklist item text must be 1..${MAX_CHECKLIST_TEXT} characters`)
|
|
629
|
+
}
|
|
630
|
+
return t
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Build a fresh unchecked checklist from plain text lines (create route /
|
|
635
|
+
* templates / tool adds).
|
|
636
|
+
* @param texts - the item texts (validated individually).
|
|
637
|
+
*/
|
|
638
|
+
export function checklistFromTexts(texts: readonly string[]): ChecklistItem[] {
|
|
639
|
+
const items = texts.map(text => ({ id: newChecklistItemId(), text: normalizeChecklistText(text), checked: false }))
|
|
640
|
+
if (items.length > MAX_CHECKLIST_ITEMS) {
|
|
641
|
+
throw new Error(`checklist may hold at most ${MAX_CHECKLIST_ITEMS} items`)
|
|
642
|
+
}
|
|
643
|
+
return items
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Validate and normalize a full checklist array (GUI update route, import):
|
|
648
|
+
* missing ids are minted, text is checked, checked flags must be booleans,
|
|
649
|
+
* checkedBy/checkedAt are kept only on checked items.
|
|
650
|
+
* @param raw - untyped array from the wire.
|
|
651
|
+
* @throws with a readable reason on any invalid entry.
|
|
652
|
+
*/
|
|
653
|
+
export function normalizeChecklist(raw: unknown): ChecklistItem[] {
|
|
654
|
+
if (!Array.isArray(raw)) throw new Error('checklist must be an array')
|
|
655
|
+
if (raw.length > MAX_CHECKLIST_ITEMS) {
|
|
656
|
+
throw new Error(`checklist may hold at most ${MAX_CHECKLIST_ITEMS} items`)
|
|
657
|
+
}
|
|
658
|
+
return raw.map((entry): ChecklistItem => {
|
|
659
|
+
if (typeof entry !== 'object' || entry === null) throw new Error('checklist item must be an object')
|
|
660
|
+
const e = entry as Record<string, unknown>
|
|
661
|
+
const text = normalizeChecklistText(typeof e.text === 'string' ? e.text : '')
|
|
662
|
+
const id = typeof e.id === 'string' && e.id.trim().length > 0 ? e.id.trim() : newChecklistItemId()
|
|
663
|
+
const checked = e.checked === true
|
|
664
|
+
const checkedBy = typeof e.checkedBy === 'string' ? e.checkedBy.trim().slice(0, 100) : undefined
|
|
665
|
+
const checkedAt = typeof e.checkedAt === 'number' && Number.isFinite(e.checkedAt) ? e.checkedAt : undefined
|
|
666
|
+
const note = typeof e.note === 'string' && e.note.trim().length > 0 ? e.note.trim().slice(0, 400) : undefined
|
|
667
|
+
if (!checked) return { id, text, checked: false }
|
|
668
|
+
return {
|
|
669
|
+
id,
|
|
670
|
+
text,
|
|
671
|
+
checked: true,
|
|
672
|
+
...(checkedBy !== undefined && checkedBy.length > 0 ? { checkedBy } : {}),
|
|
673
|
+
...(checkedAt !== undefined ? { checkedAt } : {}),
|
|
674
|
+
...(note !== undefined ? { note } : {}),
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Checklist progress: how many items are checked (absent checklist → 0/0). */
|
|
680
|
+
export function checklistProgress(task: Pick<TaskRecord, 'checklist'>): { done: number; total: number } {
|
|
681
|
+
const items = task.checklist ?? []
|
|
682
|
+
return { done: items.filter(i => i.checked).length, total: items.length }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Report string-list caps. */
|
|
686
|
+
const REPORT_LIST_CAPS = { changedFiles: 50, checks: 50, artifacts: 30 } as const
|
|
687
|
+
|
|
688
|
+
/** Per-entry cap for report lists (chars). */
|
|
689
|
+
const REPORT_ENTRY_MAX = 300
|
|
690
|
+
|
|
691
|
+
/** Validate one report string list: strings trimmed 1..300 chars. */
|
|
692
|
+
function normalizeReportList(raw: unknown, field: keyof typeof REPORT_LIST_CAPS): string[] {
|
|
693
|
+
if (raw === undefined) return []
|
|
694
|
+
if (!Array.isArray(raw)) throw new Error(`report.${field} must be an array of strings`)
|
|
695
|
+
const out = raw.map(entry => {
|
|
696
|
+
if (typeof entry !== 'string') throw new Error(`report.${field} must be an array of strings`)
|
|
697
|
+
const t = entry.trim()
|
|
698
|
+
if (t.length === 0 || t.length > REPORT_ENTRY_MAX) {
|
|
699
|
+
throw new Error(`report.${field} entries must be 1..${REPORT_ENTRY_MAX} characters`)
|
|
700
|
+
}
|
|
701
|
+
return t
|
|
702
|
+
})
|
|
703
|
+
if (out.length > REPORT_LIST_CAPS[field]) {
|
|
704
|
+
throw new Error(`report.${field} may hold at most ${REPORT_LIST_CAPS[field]} entries`)
|
|
705
|
+
}
|
|
706
|
+
return out
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Validate and normalize a structured execution report.
|
|
711
|
+
* @param raw - untyped tool/route input.
|
|
712
|
+
* @throws with a readable reason on any invalid field.
|
|
713
|
+
*/
|
|
714
|
+
export function normalizeExecutionReport(raw: unknown): ExecutionReport {
|
|
715
|
+
if (typeof raw !== 'object' || raw === null) throw new Error('report must be an object')
|
|
716
|
+
const e = raw as Record<string, unknown>
|
|
717
|
+
const summary = typeof e.summary === 'string' ? e.summary.trim() : ''
|
|
718
|
+
if (summary.length === 0 || summary.length > 2000) {
|
|
719
|
+
throw new Error('report.summary must be 1..2000 characters')
|
|
720
|
+
}
|
|
721
|
+
const risk = typeof e.risk === 'string' ? e.risk.trim().slice(0, 2000) : ''
|
|
722
|
+
return {
|
|
723
|
+
summary,
|
|
724
|
+
changedFiles: normalizeReportList(e.changedFiles, 'changedFiles'),
|
|
725
|
+
checks: normalizeReportList(e.checks, 'checks'),
|
|
726
|
+
artifacts: normalizeReportList(e.artifacts, 'artifacts'),
|
|
727
|
+
risk,
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
// ledger import validation (0.4.0)
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
/** Result classifying every task in an import file against the live ledger. */
|
|
736
|
+
export type ImportPlan = {
|
|
737
|
+
/** Structurally valid tasks whose ids are new (merge adds them). */
|
|
738
|
+
create: TaskRecord[]
|
|
739
|
+
/** Structurally valid tasks whose ids already exist (merge replaces them). */
|
|
740
|
+
overwrite: TaskRecord[]
|
|
741
|
+
/** Invalid entries with a human-readable reason (never imported). */
|
|
742
|
+
invalid: Array<{ id?: string; reason: string }>
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** One unknown-value read helper: string fields with defaults. */
|
|
746
|
+
function strOr(raw: Record<string, unknown>, key: string, fallback: string): string {
|
|
747
|
+
const v = raw[key]
|
|
748
|
+
return typeof v === 'string' ? v : fallback
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** One unknown-value read helper: finite numbers with defaults. */
|
|
752
|
+
function numOr(raw: Record<string, unknown>, key: string, fallback: number): number {
|
|
753
|
+
const v = raw[key]
|
|
754
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Validate ONE imported task record (pure): rebuilds it field by field with
|
|
759
|
+
* the normal validators, minting missing ids and re-arming cron. Executions
|
|
760
|
+
* left `running` by the exporting machine are marked failed — their
|
|
761
|
+
* settlement watchers died there and can never settle here.
|
|
762
|
+
* @param raw - the untyped record.
|
|
763
|
+
* @param now - current epoch ms (defaults for timestamps).
|
|
764
|
+
* @returns the rebuilt record, or a rejection reason.
|
|
765
|
+
*/
|
|
766
|
+
export function validateImportedTask(raw: unknown, now: number): { ok: true; task: TaskRecord } | { ok: false; reason: string } {
|
|
767
|
+
if (typeof raw !== 'object' || raw === null) return { ok: false, reason: 'not an object' }
|
|
768
|
+
const e = raw as Record<string, unknown>
|
|
769
|
+
const id = typeof e.id === 'string' ? e.id.trim() : ''
|
|
770
|
+
const fail = (reason: string): { ok: false; reason: string } => ({ ok: false, reason })
|
|
771
|
+
if (id.length === 0 || id.length > 100) return fail('missing/invalid id')
|
|
772
|
+
try {
|
|
773
|
+
const execution = normalizeExecution(
|
|
774
|
+
typeof e.execution === 'object' && e.execution !== null ? e.execution as { mode?: string; cron?: string } : {},
|
|
775
|
+
now,
|
|
776
|
+
)
|
|
777
|
+
const comments: CommentRecord[] = []
|
|
778
|
+
if (Array.isArray(e.comments)) {
|
|
779
|
+
for (const c of e.comments) {
|
|
780
|
+
if (typeof c !== 'object' || c === null) return fail('invalid comment entry')
|
|
781
|
+
const ce = c as Record<string, unknown>
|
|
782
|
+
const body = typeof ce.body === 'string' ? ce.body : ''
|
|
783
|
+
if (body.trim().length === 0 || body.length > 4000) return fail('invalid comment body')
|
|
784
|
+
comments.push({
|
|
785
|
+
id: typeof ce.id === 'string' && ce.id.length > 0 ? ce.id : newCommentId(),
|
|
786
|
+
body,
|
|
787
|
+
version: numOr(ce, 'version', 1),
|
|
788
|
+
createdAt: numOr(ce, 'createdAt', now),
|
|
789
|
+
...(typeof ce.threadId === 'string' ? { threadId: ce.threadId } : {}),
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
} else return fail('comments must be an array')
|
|
793
|
+
const executions: ExecutionRecord[] = []
|
|
794
|
+
if (Array.isArray(e.executions)) {
|
|
795
|
+
for (const x of e.executions) {
|
|
796
|
+
if (typeof x !== 'object' || x === null) return fail('invalid execution entry')
|
|
797
|
+
const xe = x as Record<string, unknown>
|
|
798
|
+
const trigger = xe.trigger === 'scheduled' ? 'scheduled' : 'manual'
|
|
799
|
+
const outcomeRaw = xe.outcome
|
|
800
|
+
if (outcomeRaw !== 'running' && outcomeRaw !== 'succeeded' && outcomeRaw !== 'failed' && outcomeRaw !== 'cancelled') {
|
|
801
|
+
return fail('invalid execution outcome')
|
|
802
|
+
}
|
|
803
|
+
// A running execution from the exporting machine can never settle
|
|
804
|
+
// here — import it as failed with the reason recorded.
|
|
805
|
+
const outcome = outcomeRaw === 'running' ? 'failed' as const : outcomeRaw
|
|
806
|
+
executions.push({
|
|
807
|
+
id: typeof xe.id === 'string' && xe.id.length > 0 ? xe.id : newExecutionId(),
|
|
808
|
+
...(typeof xe.sessionId === 'string' ? { sessionId: xe.sessionId } : {}),
|
|
809
|
+
trigger,
|
|
810
|
+
...(typeof xe.startedAt === 'number' ? { startedAt: xe.startedAt } : {}),
|
|
811
|
+
...(typeof xe.endedAt === 'number' ? { endedAt: xe.endedAt } : {}),
|
|
812
|
+
outcome,
|
|
813
|
+
...(outcomeRaw === 'running' ? { error: 'imported while still running (settlement watcher died with the exporting host)' } : (typeof xe.error === 'string' ? { error: xe.error } : {})),
|
|
814
|
+
...(typeof xe.isolation === 'string' && (xe.isolation === 'worktree' || xe.isolation === 'none') ? { isolation: xe.isolation } : {}),
|
|
815
|
+
...(typeof xe.isolationNote === 'string' ? { isolationNote: xe.isolationNote } : {}),
|
|
816
|
+
...(typeof xe.branch === 'string' ? { branch: xe.branch } : {}),
|
|
817
|
+
...(typeof xe.worktreePath === 'string' ? { worktreePath: xe.worktreePath } : {}),
|
|
818
|
+
...(typeof xe.baseCommit === 'string' ? { baseCommit: xe.baseCommit } : {}),
|
|
819
|
+
...(typeof xe.headCommit === 'string' ? { headCommit: xe.headCommit } : {}),
|
|
820
|
+
...(Array.isArray(xe.commits) ? { commits: xe.commits.filter((c): c is CommitInfo =>
|
|
821
|
+
typeof c === 'object' && c !== null && typeof (c as CommitInfo).hash === 'string' && typeof (c as CommitInfo).subject === 'string') } : {}),
|
|
822
|
+
...(typeof xe.commitsTotal === 'number' ? { commitsTotal: xe.commitsTotal } : {}),
|
|
823
|
+
...(Array.isArray(xe.dirtyFiles) ? { dirtyFiles: xe.dirtyFiles.filter((l): l is string => typeof l === 'string') } : {}),
|
|
824
|
+
...(typeof xe.dirtyFilesTotal === 'number' ? { dirtyFilesTotal: xe.dirtyFilesTotal } : {}),
|
|
825
|
+
...(typeof xe.diffStat === 'string' ? { diffStat: xe.diffStat } : {}),
|
|
826
|
+
...(typeof xe.changedFiles === 'number' ? { changedFiles: xe.changedFiles } : {}),
|
|
827
|
+
...(typeof xe.report === 'object' && xe.report !== null ? { report: normalizeExecutionReport(xe.report) } : {}),
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
} else return fail('executions must be an array')
|
|
831
|
+
const status = asStatus(strOr(e, 'status', 'todo'))
|
|
832
|
+
const actorOf = (v: unknown): Actor => (typeof v === 'object' && v !== null && (v as Actor).kind === 'agent' && typeof (v as { sessionId?: unknown }).sessionId === 'string'
|
|
833
|
+
? { kind: 'agent', sessionId: (v as { sessionId: string }).sessionId }
|
|
834
|
+
: { kind: 'user' })
|
|
835
|
+
const task: TaskRecord = {
|
|
836
|
+
id,
|
|
837
|
+
title: normalizeTitle(strOr(e, 'title', '')),
|
|
838
|
+
description: strOr(e, 'description', '').trim(),
|
|
839
|
+
prompt: normalizePrompt(strOr(e, 'prompt', '')),
|
|
840
|
+
workspaceId: strOr(e, 'workspaceId', ''),
|
|
841
|
+
urgency: asUrgency(strOr(e, 'urgency', 'normal')),
|
|
842
|
+
status,
|
|
843
|
+
blocked: e.blocked === true,
|
|
844
|
+
execution,
|
|
845
|
+
...(typeof e.model === 'object' && e.model !== null ? { model: normalizeModel(e.model) } : {}),
|
|
846
|
+
...(typeof e.isolation === 'string' && (e.isolation === 'worktree' || e.isolation === 'none') ? { isolation: e.isolation } : {}),
|
|
847
|
+
...(typeof e.presetId === 'string' && e.presetId.trim().length > 0 ? { presetId: e.presetId.trim() } : {}),
|
|
848
|
+
...(Array.isArray(e.checklist) ? { checklist: normalizeChecklist(e.checklist) } : {}),
|
|
849
|
+
...(typeof e.branch === 'string' ? { branch: e.branch } : {}),
|
|
850
|
+
...(status === 'in_progress' && typeof e.claimedBy === 'string' ? { claimedBy: e.claimedBy } : {}),
|
|
851
|
+
...(status === 'in_progress' && typeof e.claimedAt === 'number' ? { claimedAt: e.claimedAt } : {}),
|
|
852
|
+
version: Math.max(1, Math.trunc(numOr(e, 'version', 1))),
|
|
853
|
+
createdAt: numOr(e, 'createdAt', now),
|
|
854
|
+
updatedAt: numOr(e, 'updatedAt', now),
|
|
855
|
+
createdBy: actorOf(e.createdBy),
|
|
856
|
+
updatedBy: actorOf(e.updatedBy),
|
|
857
|
+
comments,
|
|
858
|
+
executions,
|
|
859
|
+
...(typeof e.executionsPruned === 'number' ? { executionsPruned: e.executionsPruned } : {}),
|
|
860
|
+
...(typeof e.trashedAt === 'number' ? { trashedAt: e.trashedAt } : {}),
|
|
861
|
+
}
|
|
862
|
+
if (task.workspaceId.length === 0) return fail('missing workspaceId')
|
|
863
|
+
return { ok: true, task }
|
|
864
|
+
} catch (error) {
|
|
865
|
+
return fail(error instanceof Error ? error.message : String(error))
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Validate a whole imported ledger and classify its tasks against the live
|
|
871
|
+
* one (pure). Duplicate ids INSIDE the file are invalid (first wins, later
|
|
872
|
+
* copies reported); schemaVersion must match {@link LEDGER_SCHEMA_VERSION}.
|
|
873
|
+
* @param raw - the parsed import file.
|
|
874
|
+
* @param knownIds - live ledger task ids.
|
|
875
|
+
* @param now - current epoch ms.
|
|
876
|
+
* @throws when the file is not a ledger or the schemaVersion is unsupported.
|
|
877
|
+
*/
|
|
878
|
+
export function validateLedgerImport(raw: unknown, knownIds: ReadonlySet<string>, now: number): ImportPlan {
|
|
879
|
+
if (typeof raw !== 'object' || raw === null) throw new Error('导入文件不是 JSON 对象')
|
|
880
|
+
const e = raw as Record<string, unknown>
|
|
881
|
+
if (e.schemaVersion !== LEDGER_SCHEMA_VERSION) {
|
|
882
|
+
throw new Error(`不支持的 schemaVersion ${String(e.schemaVersion)}(当前支持 ${LEDGER_SCHEMA_VERSION})`)
|
|
883
|
+
}
|
|
884
|
+
if (!Array.isArray(e.tasks)) throw new Error('导入文件的 tasks 不是数组')
|
|
885
|
+
const plan: ImportPlan = { create: [], overwrite: [], invalid: [] }
|
|
886
|
+
const seen = new Set<string>()
|
|
887
|
+
for (const entry of e.tasks) {
|
|
888
|
+
const id = typeof (entry as { id?: unknown })?.id === 'string' ? (entry as { id: string }).id : undefined
|
|
889
|
+
const result = validateImportedTask(entry, now)
|
|
890
|
+
if (!result.ok) {
|
|
891
|
+
plan.invalid.push({ ...(id !== undefined ? { id } : {}), reason: result.reason })
|
|
892
|
+
continue
|
|
893
|
+
}
|
|
894
|
+
if (seen.has(result.task.id)) {
|
|
895
|
+
plan.invalid.push({ id: result.task.id, reason: '文件内重复 id' })
|
|
896
|
+
continue
|
|
897
|
+
}
|
|
898
|
+
seen.add(result.task.id)
|
|
899
|
+
if (knownIds.has(result.task.id)) plan.overwrite.push(result.task)
|
|
900
|
+
else plan.create.push(result.task)
|
|
901
|
+
}
|
|
902
|
+
return plan
|
|
903
|
+
}
|
|
904
|
+
|
|
501
905
|
/**
|
|
502
906
|
* Compact list-projection of a task (token-friendly for `taskboard_list`).
|
|
503
907
|
* @param task - the task.
|
|
@@ -516,6 +920,8 @@ export type TaskSummary = {
|
|
|
516
920
|
claimOwner?: string
|
|
517
921
|
commentCount: number
|
|
518
922
|
lastExecutionOutcome?: ExecutionRecord['outcome']
|
|
923
|
+
/** Checklist progress (present only when the task has a checklist). */
|
|
924
|
+
checklist?: { done: number; total: number }
|
|
519
925
|
trashed: boolean
|
|
520
926
|
}
|
|
521
927
|
|
|
@@ -525,6 +931,7 @@ export type TaskSummary = {
|
|
|
525
931
|
*/
|
|
526
932
|
export function summarize(task: TaskRecord): TaskSummary {
|
|
527
933
|
const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined
|
|
934
|
+
const checklist = task.checklist !== undefined && task.checklist.length > 0 ? checklistProgress(task) : undefined
|
|
528
935
|
return {
|
|
529
936
|
id: task.id,
|
|
530
937
|
title: task.title,
|
|
@@ -539,6 +946,7 @@ export function summarize(task: TaskRecord): TaskSummary {
|
|
|
539
946
|
claimOwner: isClaimedBy(task),
|
|
540
947
|
commentCount: task.comments.length,
|
|
541
948
|
lastExecutionOutcome: last?.outcome,
|
|
949
|
+
...(checklist !== undefined ? { checklist } : {}),
|
|
542
950
|
trashed: task.trashedAt !== undefined,
|
|
543
951
|
}
|
|
544
952
|
}
|
package/src/shared/version.ts
CHANGED