dsh-taskboard 0.3.3 → 0.4.1
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 +27 -6
- package/lib/client.js +1201 -42
- package/lib/host/execution.js +6 -1
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +95 -2
- package/lib/host/git.js.map +1 -1
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +184 -2
- 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 +202 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +7 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +277 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +28 -0
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +45 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +192 -8
- package/src/client/board/TaskFormModal.tsx +100 -18
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/board-mount.tsx +8 -0
- package/src/client/controller.ts +152 -8
- package/src/client/sidebar-entry.ts +6 -3
- package/src/client/styles.ts +153 -0
- package/src/host/execution.ts +10 -2
- package/src/host/git.ts +77 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +215 -0
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +198 -2
- package/src/index.ts +6 -0
- package/src/shared/api.ts +54 -0
- package/src/shared/protocol.ts +344 -0
- package/src/shared/version.ts +1 -1
package/src/shared/protocol.ts
CHANGED
|
@@ -263,6 +263,38 @@ export type CommentRecord = {
|
|
|
263
263
|
/** One commit produced by an isolated execution (hash + subject). */
|
|
264
264
|
export type CommitInfo = { hash: string; subject: string }
|
|
265
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
|
+
|
|
266
298
|
/** One execution attempt of a task. */
|
|
267
299
|
export type ExecutionRecord = {
|
|
268
300
|
id: string
|
|
@@ -298,6 +330,8 @@ export type ExecutionRecord = {
|
|
|
298
330
|
diffStat?: string
|
|
299
331
|
/** How many files differ between baseCommit and headCommit. */
|
|
300
332
|
changedFiles?: number
|
|
333
|
+
/** The agent's structured report, submitted via taskboard_execution_report. */
|
|
334
|
+
report?: ExecutionReport
|
|
301
335
|
}
|
|
302
336
|
|
|
303
337
|
/** The per-model override a task may carry; absent = session default model. */
|
|
@@ -330,6 +364,12 @@ export type TaskRecord = {
|
|
|
330
364
|
* tool set. Editable any time (each run composes fresh).
|
|
331
365
|
*/
|
|
332
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[]
|
|
333
373
|
/**
|
|
334
374
|
* The task branch fixed at the FIRST worktree creation (`task/<标题>+<taskId>`).
|
|
335
375
|
* Renaming the task afterwards never changes it (history preservation).
|
|
@@ -408,6 +448,11 @@ export function newCommentId(): string {
|
|
|
408
448
|
return `c-${Date.now().toString(36)}-${suffix()}`
|
|
409
449
|
}
|
|
410
450
|
|
|
451
|
+
/** Mint a checklist item id. */
|
|
452
|
+
export function newChecklistItemId(): string {
|
|
453
|
+
return `k-${Date.now().toString(36)}-${suffix()}`
|
|
454
|
+
}
|
|
455
|
+
|
|
411
456
|
/** Mint an execution id. */
|
|
412
457
|
export function newExecutionId(): string {
|
|
413
458
|
return `e-${Date.now().toString(36)}-${suffix()}`
|
|
@@ -562,6 +607,301 @@ export function normalizeModel(raw: unknown): TaskModel {
|
|
|
562
607
|
return { provider: p, model: m }
|
|
563
608
|
}
|
|
564
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
|
+
|
|
565
905
|
/**
|
|
566
906
|
* Compact list-projection of a task (token-friendly for `taskboard_list`).
|
|
567
907
|
* @param task - the task.
|
|
@@ -580,6 +920,8 @@ export type TaskSummary = {
|
|
|
580
920
|
claimOwner?: string
|
|
581
921
|
commentCount: number
|
|
582
922
|
lastExecutionOutcome?: ExecutionRecord['outcome']
|
|
923
|
+
/** Checklist progress (present only when the task has a checklist). */
|
|
924
|
+
checklist?: { done: number; total: number }
|
|
583
925
|
trashed: boolean
|
|
584
926
|
}
|
|
585
927
|
|
|
@@ -589,6 +931,7 @@ export type TaskSummary = {
|
|
|
589
931
|
*/
|
|
590
932
|
export function summarize(task: TaskRecord): TaskSummary {
|
|
591
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
|
|
592
935
|
return {
|
|
593
936
|
id: task.id,
|
|
594
937
|
title: task.title,
|
|
@@ -603,6 +946,7 @@ export function summarize(task: TaskRecord): TaskSummary {
|
|
|
603
946
|
claimOwner: isClaimedBy(task),
|
|
604
947
|
commentCount: task.comments.length,
|
|
605
948
|
lastExecutionOutcome: last?.outcome,
|
|
949
|
+
...(checklist !== undefined ? { checklist } : {}),
|
|
606
950
|
trashed: task.trashedAt !== undefined,
|
|
607
951
|
}
|
|
608
952
|
}
|
package/src/shared/version.ts
CHANGED