@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.4
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 +17 -10
- package/README.zh.md +17 -10
- package/lib/client.js +1350 -552
- package/lib/client.js.map +1 -1
- package/lib/index.js +655 -134
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/core/controller.d.ts +1 -0
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +56 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +26 -0
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +25 -25
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +114 -5
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +82 -8
- package/src/client/schedule-zone.ts +135 -0
- package/src/core/controller.ts +7 -2
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +203 -23
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +94 -17
- package/src/protocol.ts +27 -8
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* (no persistence or notify - the controller orchestrates those), so it is
|
|
5
5
|
* unit-testable without any runtime face.
|
|
6
6
|
*/
|
|
7
|
-
import { isValidCron, nextRunAtMs } from '../schedule.ts'
|
|
7
|
+
import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from '../schedule.ts'
|
|
8
8
|
import { DEFAULT_SUBTASK_DEPTH, normalizeSubtaskDepth, taskDepth } from '../subtask.ts'
|
|
9
9
|
import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
|
|
10
10
|
|
|
@@ -32,6 +32,7 @@ export interface CreateTaskResult {
|
|
|
32
32
|
* @param now - clock instant (ms epoch).
|
|
33
33
|
* @param id - minted task id.
|
|
34
34
|
* @param maxSubtaskDepth - deployment subtask depth limit.
|
|
35
|
+
* @param hostTimeZone - zone a rule that stores no zone of its own follows.
|
|
35
36
|
*/
|
|
36
37
|
export function applyCreateTask(
|
|
37
38
|
tasks: readonly TaskRecord[],
|
|
@@ -39,6 +40,7 @@ export function applyCreateTask(
|
|
|
39
40
|
now: number,
|
|
40
41
|
id: string,
|
|
41
42
|
maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
|
|
43
|
+
hostTimeZone: string = resolveHostTimeZone(),
|
|
42
44
|
): CreateTaskResult {
|
|
43
45
|
if (input.title.trim() === '') return { task: undefined, tasks, error: 'title is required' }
|
|
44
46
|
const limit = normalizeSubtaskDepth(maxSubtaskDepth)
|
|
@@ -71,7 +73,17 @@ export function applyCreateTask(
|
|
|
71
73
|
const requested = input.schedule
|
|
72
74
|
if (requested?.enabled === true && requested.cron.trim() !== '' && isValidCron(requested.cron)) {
|
|
73
75
|
const cron = requested.cron.trim()
|
|
74
|
-
|
|
76
|
+
const requestedZone = requested.timeZone
|
|
77
|
+
// An unusable zone is refused rather than silently reinterpreted as the
|
|
78
|
+
// Host zone: the caller asked for a specific wall clock.
|
|
79
|
+
if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) {
|
|
80
|
+
return { task: undefined, tasks, error: 'invalid schedule time zone' }
|
|
81
|
+
}
|
|
82
|
+
const zone = requestedZone ?? hostTimeZone
|
|
83
|
+
const nextRunAt = nextRunAtMs(cron, now, zone)
|
|
84
|
+
if (nextRunAt !== undefined) {
|
|
85
|
+
task = withSchedule(task, { enabled: true, cron, timeZone: requestedZone, nextRunAt }, now)
|
|
86
|
+
}
|
|
75
87
|
}
|
|
76
88
|
return { task, tasks: [...tasks, task] }
|
|
77
89
|
}
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* orchestrates those). Validation and next-run computation live here, sharing
|
|
5
5
|
* the core cron parser (schedule.ts) and the withSchedule transition.
|
|
6
6
|
*/
|
|
7
|
-
import { isValidCron, nextRunAtMs } from '../schedule.ts'
|
|
7
|
+
import { isValidCron, isValidTimeZone, nextRunAtMs } from '../schedule.ts'
|
|
8
8
|
import { withSchedule, type TaskRecord } from '../tasks.ts'
|
|
9
9
|
|
|
10
10
|
/** Fields the schedule use case may change on a rule. */
|
|
11
11
|
export interface SetSchedulePatch {
|
|
12
12
|
enabled?: boolean
|
|
13
13
|
cron?: string
|
|
14
|
+
/** IANA zone for the cron wall clock; `null` clears it back to the Host zone. */
|
|
15
|
+
timeZone?: string | null
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/** Result of arming/disarming a rule. */
|
|
@@ -22,31 +24,40 @@ export interface SetScheduleResult {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
* Set an on-board task's schedule rule. A blank or invalid cron, or
|
|
26
|
-
* archived task, is rejected (state untouched); an enabled
|
|
27
|
-
* next run instant immediately
|
|
27
|
+
* Set an on-board task's schedule rule. A blank or invalid cron, an unknown or
|
|
28
|
+
* archived task, or an unusable zone is rejected (state untouched); an enabled
|
|
29
|
+
* rule computes the next run instant immediately in the rule's own zone, a
|
|
30
|
+
* disabled one carries no next-run instant.
|
|
28
31
|
* @param tasks - current ledger.
|
|
29
32
|
* @param id - the task to schedule.
|
|
30
33
|
* @param patch - rule fields to change (absent fields keep their current value).
|
|
31
34
|
* @param now - clock instant (ms epoch).
|
|
35
|
+
* @param hostTimeZone - zone a rule with no stored zone follows.
|
|
32
36
|
*/
|
|
33
37
|
export function applySetSchedule(
|
|
34
38
|
tasks: readonly TaskRecord[],
|
|
35
39
|
id: string,
|
|
36
40
|
patch: SetSchedulePatch,
|
|
37
41
|
now: number,
|
|
42
|
+
hostTimeZone: string,
|
|
38
43
|
): SetScheduleResult {
|
|
39
44
|
const task = tasks.find(candidate => candidate.id === id)
|
|
40
45
|
if (task === undefined || task.archivedAt !== undefined) return { tasks, applied: false }
|
|
41
46
|
const current = task.schedule
|
|
42
47
|
const cron = (patch.cron ?? current?.cron ?? '').trim()
|
|
43
48
|
if (cron === '' || !isValidCron(cron)) return { tasks, applied: false }
|
|
49
|
+
// An explicit null clears the stored zone; an absent key keeps it.
|
|
50
|
+
const requestedZone = patch.timeZone === undefined ? current?.timeZone : patch.timeZone ?? undefined
|
|
51
|
+
if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) return { tasks, applied: false }
|
|
44
52
|
const enabled = patch.enabled ?? current?.enabled ?? false
|
|
45
|
-
const
|
|
53
|
+
const effectiveZone = requestedZone ?? hostTimeZone
|
|
54
|
+
const nextRunAt = enabled ? nextRunAtMs(cron, now, effectiveZone) : undefined
|
|
46
55
|
if (enabled && nextRunAt === undefined) return { tasks, applied: false }
|
|
47
56
|
return {
|
|
48
57
|
tasks: tasks.map(candidate =>
|
|
49
|
-
candidate.id === id
|
|
58
|
+
candidate.id === id
|
|
59
|
+
? withSchedule(candidate, { enabled, cron, timeZone: requestedZone ?? undefined, nextRunAt }, now)
|
|
60
|
+
: candidate),
|
|
50
61
|
applied: true,
|
|
51
62
|
}
|
|
52
63
|
}
|
|
@@ -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'>> & {
|
|
19
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun'>> & {
|
|
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
|
|
@@ -108,6 +108,9 @@ export function applyUpdateTask(
|
|
|
108
108
|
if ('reuseSession' in patch) next.reuseSession = patch.reuseSession === true ? true : undefined
|
|
109
109
|
// Team execution is a boolean opt-in with the same clear-by-false rule.
|
|
110
110
|
if ('teamRun' in patch) next.teamRun = patch.teamRun === true ? true : undefined
|
|
111
|
+
// The /goal opt-in is default-ON: true (or an explicit null) returns the
|
|
112
|
+
// card to the default and stores nothing, only false pins a plain turn.
|
|
113
|
+
if ('goalRun' in patch) next.goalRun = patch.goalRun === false ? false : undefined
|
|
111
114
|
if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
|
|
112
115
|
if (mode !== undefined || 'mode' in patch) next.mode = mode
|
|
113
116
|
if (permission !== undefined || 'permission' in patch) next.permission = permission
|
package/src/host/agent-tools.ts
CHANGED
|
@@ -94,6 +94,8 @@ function scheduleView(schedule: ScheduleRule): Record<string, unknown> {
|
|
|
94
94
|
return {
|
|
95
95
|
enabled: schedule.enabled,
|
|
96
96
|
cron: schedule.cron,
|
|
97
|
+
// Absent means the rule follows the Host zone, which the board view reports.
|
|
98
|
+
...(schedule.timeZone === undefined ? {} : { timeZone: schedule.timeZone }),
|
|
97
99
|
...(schedule.nextRunAt === undefined ? {} : { nextRunAt: schedule.nextRunAt }),
|
|
98
100
|
...(schedule.lastTriggeredAt === undefined ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }),
|
|
99
101
|
}
|
|
@@ -150,6 +152,8 @@ function taskSummary(
|
|
|
150
152
|
...(task.model === undefined ? {} : { model: task.model }),
|
|
151
153
|
...(task.reuseSession === true ? { reuseSession: true } : {}),
|
|
152
154
|
...(task.teamRun === true ? { teamRun: true } : {}),
|
|
155
|
+
// Default-ON switch: only the opt-out is a deviation worth reporting.
|
|
156
|
+
...(task.goalRun === false ? { goalRun: false } : {}),
|
|
153
157
|
...(task.schedule === undefined ? {} : { schedule: scheduleView(task.schedule) }),
|
|
154
158
|
...(task.freeze === undefined ? {} : { continuationCard: true }),
|
|
155
159
|
...(task.permissionConfirmedAt === undefined ? {} : { permissionConfirmedAt: task.permissionConfirmedAt }),
|
|
@@ -334,18 +338,19 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
334
338
|
return defineTool({
|
|
335
339
|
name: 'task_board_manage',
|
|
336
340
|
description: [
|
|
337
|
-
'Move, archive, restore, or
|
|
341
|
+
'Move, archive, restore, delete, or settle one task board card.',
|
|
338
342
|
'move-todo and move-backlog are the manual column moves; a running card cannot be moved.',
|
|
343
|
+
'settle force-closes the open execution of a card the board can no longer observe (a stuck running card) and records it cancelled with the caller as the reason, so the card returns to the todo column and can be run again.',
|
|
339
344
|
'archive takes the whole subtask tree off the board and is refused while any member has an unsettled execution; restore brings the task, its ancestors and its subtree back; delete removes one card and is refused while it still has subtasks (detach or delete them first) or while it runs.',
|
|
340
345
|
'It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI.',
|
|
341
|
-
'Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task.',
|
|
346
|
+
'Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task, 卡住, stuck, 强制结算, settle.',
|
|
342
347
|
].join(' '),
|
|
343
348
|
parameters: {
|
|
344
349
|
taskId: { type: 'string', required: true, description: 'The task to act on.' },
|
|
345
350
|
action: {
|
|
346
351
|
type: 'string',
|
|
347
352
|
required: true,
|
|
348
|
-
enum: ['move-todo', 'move-backlog', 'archive', 'restore', 'delete'],
|
|
353
|
+
enum: ['move-todo', 'move-backlog', 'archive', 'restore', 'delete', 'settle'],
|
|
349
354
|
description: 'The lifecycle operation to perform.',
|
|
350
355
|
},
|
|
351
356
|
},
|
|
@@ -356,6 +361,7 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
356
361
|
'move-todo': { kind: 'move', taskId: args.taskId, status: 'todo' },
|
|
357
362
|
'move-backlog': { kind: 'move', taskId: args.taskId, status: 'backlog' },
|
|
358
363
|
archive: { kind: 'archive', taskId: args.taskId },
|
|
364
|
+
settle: { kind: 'settle', taskId: args.taskId },
|
|
359
365
|
restore: { kind: 'restore', taskId: args.taskId },
|
|
360
366
|
delete: { kind: 'delete', taskId: args.taskId },
|
|
361
367
|
}
|
|
@@ -381,7 +387,8 @@ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
381
387
|
return defineTool({
|
|
382
388
|
name: 'task_board_schedule',
|
|
383
389
|
description: [
|
|
384
|
-
'Arm, change, or disarm a task scheduled runs (5-field cron
|
|
390
|
+
'Arm, change, or disarm a task scheduled runs (5-field cron; day-of-month and day-of-week follow Vixie semantics: both restricted means OR, otherwise AND).',
|
|
391
|
+
'The cron wall clock is read in the rule stored IANA time zone; omit timeZone to use the Host zone reported by task_board_list/task_board_get. DST gaps are skipped and an ambiguous fall-back time fires once, at the earlier instant.',
|
|
385
392
|
'A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.',
|
|
386
393
|
'A schedule whose tree contains an unconfirmed above-default permission is refused and rolls to its next occurrence; the reason is reported in the list board summary as schedulerError.',
|
|
387
394
|
'Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence.',
|
|
@@ -391,20 +398,27 @@ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
391
398
|
taskId: { type: 'string', required: true, description: 'The task whose schedule changes.' },
|
|
392
399
|
enabled: { type: 'boolean', description: 'Arm (true) or disarm (false) the schedule.' },
|
|
393
400
|
cron: { type: 'string', description: '5-field cron expression: minute hour day-of-month month day-of-week.' },
|
|
401
|
+
timeZone: {
|
|
402
|
+
type: 'string',
|
|
403
|
+
description: 'IANA zone the cron wall clock is read in (for example Asia/Shanghai). Omit to keep the stored zone, or pass an empty string to clear it back to the Host zone.',
|
|
404
|
+
},
|
|
394
405
|
},
|
|
395
406
|
output: { schema: { type: 'json' }, render: renderJson },
|
|
396
407
|
async execute(args, exec) {
|
|
397
|
-
if (args.enabled === undefined && args.cron === undefined) {
|
|
398
|
-
return refused('nothing-to-change', 'pass enabled and/or cron')
|
|
408
|
+
if (args.enabled === undefined && args.cron === undefined && args.timeZone === undefined) {
|
|
409
|
+
return refused('nothing-to-change', 'pass enabled and/or cron and/or timeZone')
|
|
399
410
|
}
|
|
400
411
|
// A missing card must read as a missing card: the ledger reports an
|
|
401
412
|
// unknown id through the same refusal as a malformed cron.
|
|
402
413
|
if (!host.snapshot().tasks.some(task => task.id === args.taskId)) {
|
|
403
414
|
return refused('task-not-found', 'no task with id ' + args.taskId)
|
|
404
415
|
}
|
|
405
|
-
const patch: { enabled?: boolean; cron?: string } = {
|
|
416
|
+
const patch: { enabled?: boolean; cron?: string; timeZone?: string | null } = {
|
|
406
417
|
...(args.enabled === undefined ? {} : { enabled: args.enabled }),
|
|
407
418
|
...(args.cron === undefined ? {} : { cron: args.cron }),
|
|
419
|
+
// An empty string is the documented way to clear the stored zone, so
|
|
420
|
+
// the model never has to emit an explicit null through JSON args.
|
|
421
|
+
...(args.timeZone === undefined ? {} : { timeZone: args.timeZone === '' ? null : args.timeZone }),
|
|
408
422
|
}
|
|
409
423
|
try {
|
|
410
424
|
const snapshot = host.apply(crypto.randomUUID(), { kind: 'set-schedule', taskId: args.taskId, patch }, callingSessionId(exec))
|
|
@@ -514,6 +528,7 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
514
528
|
model: { type: 'string', description: 'Pinned model as provider/model (or a model id); omit for the host default or the parent value.' },
|
|
515
529
|
reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session instead of a fresh conversation.' },
|
|
516
530
|
teamRun: { type: 'boolean', description: 'Run this task as an Agent Team: running it starts one Team Lead session and the Host spawns a teammate per subtask inside it. Omit or false for one independent session per member. Refused when the deployment serves no Agent Teams service.' },
|
|
531
|
+
goalRun: { type: 'boolean', description: 'Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes. Default true (omit to keep it); pass false for a single plain turn.' },
|
|
517
532
|
tags: {
|
|
518
533
|
type: 'array',
|
|
519
534
|
description: 'Labels: the name renders as a badge and drives the board filter; promptPrefix is injected ahead of the execution prompt on every run.',
|
|
@@ -555,6 +570,7 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
555
570
|
...(args.model === undefined || args.model === '' ? {} : { model: args.model }),
|
|
556
571
|
...(args.reuseSession === true ? { reuseSession: true } : {}),
|
|
557
572
|
...(args.teamRun === true ? { teamRun: true } : {}),
|
|
573
|
+
...(args.goalRun === false ? { goalRun: false } : {}),
|
|
558
574
|
...(tags.length === 0 ? {} : { tags }),
|
|
559
575
|
...(args.schedule === undefined ? {} : { schedule: { enabled: args.schedule.enabled, cron: args.schedule.cron } }),
|
|
560
576
|
}
|
|
@@ -587,10 +603,25 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
587
603
|
prompt: { type: 'string', description: 'New execution prompt.' },
|
|
588
604
|
workspaceId: { type: 'string', description: 'New workspace id; an empty string clears it.' },
|
|
589
605
|
mode: { type: 'string', description: 'New agent preset id; an empty string clears it.' },
|
|
590
|
-
|
|
606
|
+
// `enum` with a clearing value would advertise the empty string as a
|
|
607
|
+
// legal member of the enum. Some OpenAI-compatible gateways forward the
|
|
608
|
+
// tool schema to Gemini, which rejects an empty enum member outright
|
|
609
|
+
// ("cannot be empty") and fails the whole request - not just this tool -
|
|
610
|
+
// even for a plain greeting, because the schema ships with every call
|
|
611
|
+
// (issue #1748). Splitting the clear value into its own `const` branch
|
|
612
|
+
// keeps both the real enum intact and the wire representation valid for
|
|
613
|
+
// those gateways.
|
|
614
|
+
permission: {
|
|
615
|
+
oneOf: [
|
|
616
|
+
{ type: 'string', enum: [...TASK_PERMISSIONS] },
|
|
617
|
+
{ type: 'string', const: '' },
|
|
618
|
+
],
|
|
619
|
+
description: 'New permission preset; an empty string clears it back to the session default.',
|
|
620
|
+
},
|
|
591
621
|
model: { type: 'string', description: 'New pinned model; an empty string clears it.' },
|
|
592
622
|
reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session.' },
|
|
593
623
|
teamRun: { type: 'boolean', description: 'Switches this task between a plain cascade (one session per member) and an Agent Team run (Lead session plus a teammate per subtask).' },
|
|
624
|
+
goalRun: { type: 'boolean', description: 'Whether each run starts with dsh built-in /goal (default true). Pass false to run one plain turn instead.' },
|
|
594
625
|
tags: {
|
|
595
626
|
type: 'array',
|
|
596
627
|
description: 'Replacement label set; an empty array clears all labels.',
|
|
@@ -619,6 +650,7 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
619
650
|
}
|
|
620
651
|
if (has(args, 'reuseSession')) patch.reuseSession = args.reuseSession === true
|
|
621
652
|
if (has(args, 'teamRun')) patch.teamRun = args.teamRun === true
|
|
653
|
+
if (has(args, 'goalRun')) patch.goalRun = args.goalRun === false ? false : true
|
|
622
654
|
if (has(args, 'tags')) {
|
|
623
655
|
const tags = args.tags ?? []
|
|
624
656
|
patch.tags = tags.length === 0
|
package/src/host-ledger.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'
|
|
|
3
3
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
|
-
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
6
|
+
import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
|
|
7
7
|
import { isTaskRecord, parseLedger } from './core/store.ts'
|
|
8
8
|
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
9
|
import {
|
|
@@ -21,7 +21,7 @@ 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
23
|
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
24
|
-
import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
24
|
+
import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_OLDER_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
25
25
|
import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
26
26
|
|
|
27
27
|
interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
|
|
@@ -84,12 +84,21 @@ export interface OpenExecutionReference {
|
|
|
84
84
|
readonly executionId: string
|
|
85
85
|
readonly sessionId: string | undefined
|
|
86
86
|
readonly startedAt: number
|
|
87
|
+
/**
|
|
88
|
+
* True for a member of a team-mode run other than its Lead. A teammate is a
|
|
89
|
+
* durable member of the Team: it stays alive after its turn ends, so the
|
|
90
|
+
* roster may keep reporting its session as running, and the turn it completed
|
|
91
|
+
* is the only verdict it exposes.
|
|
92
|
+
*/
|
|
93
|
+
readonly teamMember: boolean
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/** Minimal value copy used by the Host scheduler. */
|
|
90
97
|
export interface DueScheduleReference {
|
|
91
98
|
readonly taskId: string
|
|
92
99
|
readonly cron: string
|
|
100
|
+
/** Zone this rule's wall clock is read in. */
|
|
101
|
+
readonly timeZone: string
|
|
93
102
|
readonly nextRunAt: number
|
|
94
103
|
}
|
|
95
104
|
|
|
@@ -106,7 +115,12 @@ interface CachedRequest {
|
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
function timeZone(): string {
|
|
109
|
-
return
|
|
118
|
+
return resolveHostTimeZone()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The zone a schedule is evaluated in: its own, or the Host zone when it stores none. */
|
|
122
|
+
function scheduleZone(schedule: { timeZone?: string }): string {
|
|
123
|
+
return schedule.timeZone ?? timeZone()
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
@@ -381,6 +395,7 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
|
381
395
|
schedule: {
|
|
382
396
|
enabled: false,
|
|
383
397
|
cron: schedule.cron,
|
|
398
|
+
...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
|
|
384
399
|
nextRunAt: undefined,
|
|
385
400
|
lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
|
|
386
401
|
? schedule.lastTriggeredAt
|
|
@@ -426,6 +441,10 @@ export class HostTaskLedger {
|
|
|
426
441
|
}
|
|
427
442
|
this.repairSchedules(true)
|
|
428
443
|
this.reconcileInterruptedStarts()
|
|
444
|
+
// Boot recovery also folds every already-decided run: a team run whose
|
|
445
|
+
// Lead recorded its verdict in an earlier process, and a lineage whose
|
|
446
|
+
// last settle never reached its parent, both leave the running column here.
|
|
447
|
+
this.finalizeReadyRuns(false)
|
|
429
448
|
// Persist a freshly generated ledger identity and any recovery error
|
|
430
449
|
// immediately, even when there are no tasks to trigger a later action.
|
|
431
450
|
this.commit(false)
|
|
@@ -488,6 +507,15 @@ export class HostTaskLedger {
|
|
|
488
507
|
*/
|
|
489
508
|
runtimeView(): LedgerRuntimeView {
|
|
490
509
|
let armedSchedules = 0
|
|
510
|
+
// Run groups opened by a team-mode card: every other member of those groups
|
|
511
|
+
// runs as a teammate inside that card's Lead session.
|
|
512
|
+
const teamGroups = new Set<string>()
|
|
513
|
+
for (const task of this.document.tasks) {
|
|
514
|
+
if (task.teamRun !== true) continue
|
|
515
|
+
for (const execution of task.executions) {
|
|
516
|
+
if (execution.runGroupId !== undefined) teamGroups.add(execution.runGroupId)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
491
519
|
const openExecutions: OpenExecutionReference[] = []
|
|
492
520
|
for (const task of this.document.tasks) {
|
|
493
521
|
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
@@ -501,6 +529,9 @@ export class HostTaskLedger {
|
|
|
501
529
|
executionId: execution.id,
|
|
502
530
|
sessionId: execution.sessionId,
|
|
503
531
|
startedAt: execution.startedAt,
|
|
532
|
+
teamMember: task.teamRun !== true
|
|
533
|
+
&& execution.runGroupId !== undefined
|
|
534
|
+
&& teamGroups.has(execution.runGroupId),
|
|
504
535
|
})
|
|
505
536
|
}
|
|
506
537
|
}
|
|
@@ -543,7 +574,7 @@ export class HostTaskLedger {
|
|
|
543
574
|
if (task.archivedAt !== undefined) continue
|
|
544
575
|
const schedule = task.schedule
|
|
545
576
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
546
|
-
due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt })
|
|
577
|
+
due.push({ taskId: task.id, cron: schedule.cron, timeZone: scheduleZone(schedule), nextRunAt: schedule.nextRunAt })
|
|
547
578
|
}
|
|
548
579
|
return due
|
|
549
580
|
}
|
|
@@ -626,7 +657,7 @@ export class HostTaskLedger {
|
|
|
626
657
|
const schedule = task.schedule
|
|
627
658
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
|
|
628
659
|
changed = true
|
|
629
|
-
return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now) }, updatedAt: now }
|
|
660
|
+
return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule)) }, updatedAt: now }
|
|
630
661
|
})
|
|
631
662
|
if (changed) this.commit()
|
|
632
663
|
}
|
|
@@ -681,16 +712,26 @@ export class HostTaskLedger {
|
|
|
681
712
|
})
|
|
682
713
|
changed = true
|
|
683
714
|
}
|
|
715
|
+
// A team run is governed by its Lead: the Lead's own outcome closes whatever
|
|
716
|
+
// the Team still holds open, so the tree can never wait on a teammate that
|
|
717
|
+
// will never report one.
|
|
718
|
+
if (task.teamRun === true
|
|
719
|
+
&& this.closeTeamMembers(taskId, groupId, execution.ownResult ?? outcome, execution.ownError ?? error, now)) changed = true
|
|
684
720
|
if (this.settleCascade(taskId, groupId, now)) changed = true
|
|
685
721
|
if (changed) this.commit()
|
|
686
722
|
}
|
|
687
723
|
|
|
688
724
|
attachSession(taskId: string, executionId: string, sessionId: string): void {
|
|
689
725
|
const now = this.now()
|
|
690
|
-
|
|
691
|
-
|
|
726
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
727
|
+
// A run the Lead's verdict already closed keeps its record: attaching a
|
|
728
|
+
// session to a settled execution would rewrite history the Host no longer
|
|
729
|
+
// observes (a teammate spawn that resolved after its Team was closed).
|
|
730
|
+
if (task?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) return
|
|
731
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
|
|
732
|
+
...item,
|
|
692
733
|
updatedAt: now,
|
|
693
|
-
executions:
|
|
734
|
+
executions: item.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
|
|
694
735
|
})
|
|
695
736
|
this.commit()
|
|
696
737
|
}
|
|
@@ -717,17 +758,22 @@ export class HostTaskLedger {
|
|
|
717
758
|
: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}`
|
|
718
759
|
this.repairSchedules(true, false)
|
|
719
760
|
this.reconcileInterruptedStarts(false)
|
|
761
|
+
// An imported document may carry a decided-but-open run; fold it in the
|
|
762
|
+
// same action (apply() commits once at the end).
|
|
763
|
+
this.finalizeReadyRuns(false)
|
|
720
764
|
break
|
|
721
765
|
}
|
|
722
766
|
case 'create': {
|
|
723
767
|
if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
|
|
724
|
-
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
|
|
768
|
+
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
|
|
769
|
+
|| !isValidTimeZone(action.input.schedule.timeZone ?? timeZone())
|
|
770
|
+
|| nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === undefined)) {
|
|
725
771
|
throw new Error('invalid schedule')
|
|
726
772
|
}
|
|
727
773
|
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
728
774
|
? action.input
|
|
729
775
|
: { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
|
|
730
|
-
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
|
|
776
|
+
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone())
|
|
731
777
|
if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
|
|
732
778
|
this.document.tasks = [...result.tasks]
|
|
733
779
|
break
|
|
@@ -786,6 +832,34 @@ export class HostTaskLedger {
|
|
|
786
832
|
this.document.tasks = [...result.tasks]
|
|
787
833
|
break
|
|
788
834
|
}
|
|
835
|
+
case 'settle': {
|
|
836
|
+
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
837
|
+
if (task === undefined) throw new Error('task not found')
|
|
838
|
+
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
839
|
+
if (!task.executions.some(entry => entry.endedAt === undefined)) throw new Error('task has no open execution')
|
|
840
|
+
// Force-close the executions this card governs. The verdict is
|
|
841
|
+
// 'cancelled', never 'succeeded': a card whose execution produced no
|
|
842
|
+
// evidence of a completed turn must not be recorded as finished work,
|
|
843
|
+
// and a cancelled settlement returns the card to the todo column so the
|
|
844
|
+
// operator can run it again. Nothing else clears a running card: move,
|
|
845
|
+
// archive and delete all refuse one, which is what made a stuck card
|
|
846
|
+
// unrecoverable from the board.
|
|
847
|
+
const reason = 'execution closed manually by '
|
|
848
|
+
+ (initiator === undefined || initiator === '' ? 'the operator' : initiator)
|
|
849
|
+
+ ': no verdict was recorded'
|
|
850
|
+
for (const member of cascadeTargets(this.document.tasks, action.taskId, this.maxSubtaskDepth)) {
|
|
851
|
+
for (const execution of member.executions) {
|
|
852
|
+
if (execution.endedAt !== undefined) continue
|
|
853
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== member.id
|
|
854
|
+
? item
|
|
855
|
+
: settleExecution(item, execution.id, 'cancelled', now, reason))
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// Fold whatever became ready in the same action: an ancestor whose last
|
|
859
|
+
// member this closed leaves the running column with it.
|
|
860
|
+
this.finalizeReadyRuns(false)
|
|
861
|
+
break
|
|
862
|
+
}
|
|
789
863
|
case 'restore': {
|
|
790
864
|
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
|
|
791
865
|
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
@@ -805,7 +879,7 @@ export class HostTaskLedger {
|
|
|
805
879
|
case 'set-schedule': {
|
|
806
880
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
807
881
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
808
|
-
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now)
|
|
882
|
+
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now, timeZone())
|
|
809
883
|
if (!result.applied) throw new Error('invalid schedule')
|
|
810
884
|
this.document.tasks = [...result.tasks]
|
|
811
885
|
break
|
|
@@ -959,6 +1033,95 @@ export class HostTaskLedger {
|
|
|
959
1033
|
return changed
|
|
960
1034
|
}
|
|
961
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* Close every run that is already decided, and fold every lineage whose
|
|
1038
|
+
* members are all settled. The Host poll calls this on every tick, so a run
|
|
1039
|
+
* can no longer be left in the running column by a settle nobody observed:
|
|
1040
|
+
*
|
|
1041
|
+
* - A team run is governed by its Lead. Once the Lead's own outcome is
|
|
1042
|
+
* recorded, its still-open members are settled with that verdict. A
|
|
1043
|
+
* teammate is a durable member of the Team and may never report a turn of
|
|
1044
|
+
* its own, and without this the whole lineage waits on it forever.
|
|
1045
|
+
* - A cascade parent whose members are all settled is finalized here even
|
|
1046
|
+
* when the settle that completed the tree happened in another process, or
|
|
1047
|
+
* was interrupted between its child write and its parent write.
|
|
1048
|
+
*
|
|
1049
|
+
* Idempotent: a run that is already folded is left untouched, so a caller may
|
|
1050
|
+
* invoke it on every tick.
|
|
1051
|
+
* @param persist - false to leave the document for the caller to commit.
|
|
1052
|
+
* @returns whether the document changed.
|
|
1053
|
+
*/
|
|
1054
|
+
finalizeReadyRuns(persist = true): boolean {
|
|
1055
|
+
const now = this.now()
|
|
1056
|
+
let changed = false
|
|
1057
|
+
const groups = new Set<string>()
|
|
1058
|
+
for (const task of this.document.tasks) {
|
|
1059
|
+
for (const execution of task.executions) {
|
|
1060
|
+
if (execution.endedAt === undefined && execution.runGroupId !== undefined) groups.add(execution.runGroupId)
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
for (const groupId of groups) {
|
|
1064
|
+
const lead = this.document.tasks.find(task => task.teamRun === true
|
|
1065
|
+
&& task.executions.some(entry => entry.runGroupId === groupId))
|
|
1066
|
+
const verdict = lead === undefined ? undefined : this.leadVerdict(lead, groupId)
|
|
1067
|
+
if (lead !== undefined && verdict !== undefined
|
|
1068
|
+
&& this.closeTeamMembers(lead.id, groupId, verdict.result, verdict.error, now)) changed = true
|
|
1069
|
+
// Fold the lineage: a member may itself be a parent of a deeper member.
|
|
1070
|
+
for (const task of this.document.tasks) {
|
|
1071
|
+
if (this.settleCascade(task.id, groupId, now)) changed = true
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (changed && persist) this.commit()
|
|
1075
|
+
return changed
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* The verdict a team run's Lead has already recorded: its own turn outcome
|
|
1080
|
+
* once that turn ended, or the folded outcome once its card settled.
|
|
1081
|
+
*/
|
|
1082
|
+
private leadVerdict(lead: TaskRecord, groupId: string): { result: ExecutionOutcome; error: string | undefined } | undefined {
|
|
1083
|
+
const execution = lead.executions.find(entry => entry.runGroupId === groupId)
|
|
1084
|
+
const result = execution?.ownResult ?? execution?.result
|
|
1085
|
+
if (result === undefined) return undefined
|
|
1086
|
+
return { result, error: execution?.ownError ?? execution?.error }
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Settle every still-open member of a team run with the Lead's verdict.
|
|
1091
|
+
* A teammate that already reported its own outcome keeps it: the ordinary
|
|
1092
|
+
* fold still lets a failure dominate.
|
|
1093
|
+
* @param leadId - the Lead card's task id (never settled here).
|
|
1094
|
+
* @param groupId - the team run's group.
|
|
1095
|
+
* @param verdict - the Lead's outcome, inherited by members that never reported.
|
|
1096
|
+
* @param error - the Lead's failure text, inherited with a failed verdict.
|
|
1097
|
+
* @param now - clock instant (ms epoch).
|
|
1098
|
+
* @returns whether the document changed.
|
|
1099
|
+
*/
|
|
1100
|
+
private closeTeamMembers(leadId: string, groupId: string, verdict: ExecutionOutcome, error: string | undefined, now: number): boolean {
|
|
1101
|
+
const members: string[] = []
|
|
1102
|
+
let recorded = false
|
|
1103
|
+
this.document.tasks = this.document.tasks.map(task => {
|
|
1104
|
+
if (task.id === leadId) return task
|
|
1105
|
+
const execution = openGroupExecution(task, groupId)
|
|
1106
|
+
if (execution === undefined) return task
|
|
1107
|
+
members.push(task.id)
|
|
1108
|
+
if (execution.ownResult !== undefined) return task
|
|
1109
|
+
recorded = true
|
|
1110
|
+
return {
|
|
1111
|
+
...task,
|
|
1112
|
+
updatedAt: now,
|
|
1113
|
+
executions: task.executions.map(entry => entry.id === execution.id
|
|
1114
|
+
? { ...entry, ownResult: verdict, ownError: error }
|
|
1115
|
+
: entry),
|
|
1116
|
+
}
|
|
1117
|
+
})
|
|
1118
|
+
let changed = recorded
|
|
1119
|
+
for (const memberId of members) {
|
|
1120
|
+
if (this.settleCascade(memberId, groupId, now)) changed = true
|
|
1121
|
+
}
|
|
1122
|
+
return changed
|
|
1123
|
+
}
|
|
1124
|
+
|
|
962
1125
|
private repairSchedules(skipPast: boolean, persist = true): void {
|
|
963
1126
|
const now = this.now()
|
|
964
1127
|
let changed = false
|
|
@@ -966,7 +1129,7 @@ export class HostTaskLedger {
|
|
|
966
1129
|
const schedule = task.schedule
|
|
967
1130
|
if (schedule === undefined || !schedule.enabled) return task
|
|
968
1131
|
if (!skipPast && schedule.nextRunAt !== undefined) return task
|
|
969
|
-
const next = nextRunAtMs(schedule.cron, now)
|
|
1132
|
+
const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
|
|
970
1133
|
if (next === undefined) {
|
|
971
1134
|
changed = true
|
|
972
1135
|
this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
|
|
@@ -1003,35 +1166,52 @@ export class HostTaskLedger {
|
|
|
1003
1166
|
}
|
|
1004
1167
|
|
|
1005
1168
|
/**
|
|
1006
|
-
* Field-preserving
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1169
|
+
* Field-preserving migration of a pre-v4 document. It first proves every task
|
|
1170
|
+
* row is structurally valid, so a document that would silently drop or coerce
|
|
1171
|
+
* rows fails loudly instead (no quarantined-empty restart), and then reuses
|
|
1172
|
+
* the current normalization.
|
|
1173
|
+
*
|
|
1174
|
+
* v4 adds `ScheduleRule.timeZone`. A rule written before v4 was evaluated in
|
|
1175
|
+
* whatever zone the Host process happened to report, so the migration stamps
|
|
1176
|
+
* that zone onto every enabled rule: the trigger instant is preserved exactly
|
|
1177
|
+
* (the stored `nextRunAt` already encodes the old zone), but the rule stops
|
|
1178
|
+
* following a later `TZ` change, which is what made an existing schedule
|
|
1179
|
+
* silently move when the Host's zone changed.
|
|
1010
1180
|
*/
|
|
1011
1181
|
private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
|
|
1012
1182
|
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
|
|
1013
|
-
throw new Error(
|
|
1183
|
+
throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
|
|
1014
1184
|
}
|
|
1015
|
-
|
|
1185
|
+
// Every row passed `isTaskRecord` above, so the stamped rows are still
|
|
1186
|
+
// task records; the cast only re-narrows the unknown-typed on-disk array.
|
|
1187
|
+
const rows = (parsed.tasks as readonly unknown[]).map((value) => {
|
|
1188
|
+
const row = value as { schedule?: unknown }
|
|
1189
|
+
const schedule = row.schedule
|
|
1190
|
+
if (typeof schedule !== 'object' || schedule === null) return value
|
|
1191
|
+
if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
|
|
1192
|
+
return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
|
|
1193
|
+
})
|
|
1194
|
+
return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
|
|
1016
1195
|
}
|
|
1017
1196
|
|
|
1018
1197
|
private load(dir: string): LedgerDocument {
|
|
1019
1198
|
const existed = existsSync(this.file)
|
|
1020
|
-
// schemaVersion stays unknown-typed here: on-disk documents may be v2
|
|
1021
|
-
// (legacy),
|
|
1199
|
+
// schemaVersion stays unknown-typed here: on-disk documents may be v2 or
|
|
1200
|
+
// v3 (legacy), v4, or any future/invalid value the branches below sort out.
|
|
1022
1201
|
let parsed: ParsedLedgerDocument
|
|
1023
1202
|
try {
|
|
1024
1203
|
parsed = JSON.parse(readFileSync(this.file, 'utf8')) as ParsedLedgerDocument
|
|
1025
1204
|
} catch (error) {
|
|
1026
1205
|
return this.recoverCorrupt(dir, existed, error)
|
|
1027
1206
|
}
|
|
1028
|
-
if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION
|
|
1207
|
+
if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION
|
|
1208
|
+
|| parsed.schemaVersion === TASK_BOARD_OLDER_SCHEMA_VERSION) {
|
|
1029
1209
|
try {
|
|
1030
1210
|
return this.migrateLegacyDocument(parsed)
|
|
1031
1211
|
} catch (error) {
|
|
1032
|
-
// Migration failure is explicit: the original
|
|
1212
|
+
// Migration failure is explicit: the original file stays in place
|
|
1033
1213
|
// for manual recovery and the ledger refuses to start (fail closed).
|
|
1034
|
-
throw new Error(`ledger
|
|
1214
|
+
throw new Error(`ledger v${String(parsed.schemaVersion)} to v${TASK_BOARD_SCHEMA_VERSION} migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
1035
1215
|
}
|
|
1036
1216
|
}
|
|
1037
1217
|
try {
|