dsh-taskboard 0.1.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/LICENSE +201 -0
- package/README.md +169 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +2085 -0
- package/lib/host/execution.js +189 -0
- package/lib/host/execution.js.map +1 -0
- package/lib/host/protocol-text.js +37 -0
- package/lib/host/protocol-text.js.map +1 -0
- package/lib/host/routes.js +369 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/scheduler.js +91 -0
- package/lib/host/scheduler.js.map +1 -0
- package/lib/host/sdk.js +145 -0
- package/lib/host/sdk.js.map +1 -0
- package/lib/host/store.js +112 -0
- package/lib/host/store.js.map +1 -0
- package/lib/host/tools.js +620 -0
- package/lib/host/tools.js.map +1 -0
- package/lib/index.js +91 -0
- package/lib/index.js.map +1 -0
- package/lib/invariant.js +22 -0
- package/lib/invariant.js.map +1 -0
- package/lib/shared/api.js +9 -0
- package/lib/shared/api.js.map +1 -0
- package/lib/shared/protocol.js +279 -0
- package/lib/shared/protocol.js.map +1 -0
- package/package.json +74 -0
- package/src/client/api.ts +90 -0
- package/src/client/board/NewTaskModal.tsx +8 -0
- package/src/client/board/TaskBoard.tsx +184 -0
- package/src/client/board/TaskCard.tsx +61 -0
- package/src/client/board/TaskDetail.tsx +210 -0
- package/src/client/board/TaskFormModal.tsx +257 -0
- package/src/client/board-mount.tsx +92 -0
- package/src/client/controller.ts +241 -0
- package/src/client/index.ts +87 -0
- package/src/client/sidebar-entry.ts +165 -0
- package/src/client/styles.ts +391 -0
- package/src/host/execution.ts +244 -0
- package/src/host/protocol-text.ts +37 -0
- package/src/host/routes.ts +387 -0
- package/src/host/scheduler.ts +107 -0
- package/src/host/sdk.ts +200 -0
- package/src/host/store.ts +139 -0
- package/src/host/tools.ts +631 -0
- package/src/index.ts +124 -0
- package/src/invariant.ts +22 -0
- package/src/shared/api.ts +98 -0
- package/src/shared/protocol.ts +475 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side cron scheduler: one tick per minute over the ledger's scheduled
|
|
3
|
+
* tasks. A due task (nextRunAt reached, not running, not trashed) first has
|
|
4
|
+
* its next run advanced to the next cron match — then it executes through
|
|
5
|
+
* the same path as the manual button. Missed windows (host was down, tab
|
|
6
|
+
* closed — irrelevant here, this is the host process) simply advance: a
|
|
7
|
+
* nextRunAt more than one window in the past is skipped, not caught up.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-taskboard/host/scheduler
|
|
10
|
+
*/
|
|
11
|
+
import { nextCronTime, parseCron, type TaskLedger } from '../shared/protocol.ts'
|
|
12
|
+
import type { ExecutionService } from './execution.ts'
|
|
13
|
+
import type { TaskStore } from './store.ts'
|
|
14
|
+
|
|
15
|
+
/** Tick cadence. */
|
|
16
|
+
const TICK_MS = 60_000
|
|
17
|
+
|
|
18
|
+
/** A due window older than this is skipped (missed while the host was down). */
|
|
19
|
+
const SKIP_AFTER_MS = 5 * 60_000
|
|
20
|
+
|
|
21
|
+
/** Everything the scheduler needs. */
|
|
22
|
+
export interface SchedulerDeps {
|
|
23
|
+
store: TaskStore
|
|
24
|
+
execution: Pick<ExecutionService, 'run'>
|
|
25
|
+
now: () => number
|
|
26
|
+
/** Timer face (injectable for tests). */
|
|
27
|
+
timers?: {
|
|
28
|
+
setInterval(fn: () => void, ms: number): unknown
|
|
29
|
+
clearInterval(handle: unknown): void
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The cron scheduler.
|
|
35
|
+
*/
|
|
36
|
+
export class SchedulerService {
|
|
37
|
+
private handle: unknown
|
|
38
|
+
|
|
39
|
+
/** @param deps - store + execution + clock. */
|
|
40
|
+
constructor(private readonly deps: SchedulerDeps) {}
|
|
41
|
+
|
|
42
|
+
/** Start ticking. */
|
|
43
|
+
start(): void {
|
|
44
|
+
const timers = this.deps.timers ?? {
|
|
45
|
+
setInterval: (fn: () => void, ms: number) => setInterval(fn, ms),
|
|
46
|
+
clearInterval: (handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>),
|
|
47
|
+
}
|
|
48
|
+
this.handle = timers.setInterval(() => { void this.tick() }, TICK_MS)
|
|
49
|
+
// Catch up promptly on host restart: run one tick soon after start.
|
|
50
|
+
setTimeout(() => { void this.tick() }, 3_000)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Stop ticking. */
|
|
54
|
+
dispose(): void {
|
|
55
|
+
if (this.handle === undefined) return
|
|
56
|
+
const timers = this.deps.timers ?? { clearInterval: (h: unknown) => clearInterval(h as ReturnType<typeof setInterval>) }
|
|
57
|
+
timers.clearInterval(this.handle)
|
|
58
|
+
this.handle = undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One scheduler pass (exported for tests). */
|
|
62
|
+
async tick(): Promise<void> {
|
|
63
|
+
const now = this.deps.now()
|
|
64
|
+
const ledger: TaskLedger = this.deps.store.snapshot()
|
|
65
|
+
for (const task of ledger.tasks) {
|
|
66
|
+
if (task.execution.mode !== 'scheduled' || task.execution.cron === undefined) continue
|
|
67
|
+
if (task.execution.nextRunAt === undefined) continue
|
|
68
|
+
if (task.status === 'in_progress' || task.trashedAt !== undefined) continue
|
|
69
|
+
if (task.execution.nextRunAt > now) continue
|
|
70
|
+
const missed = now - task.execution.nextRunAt > SKIP_AFTER_MS
|
|
71
|
+
|
|
72
|
+
// Advance the schedule FIRST (idempotent under re-ticks), then run
|
|
73
|
+
// unless the window was missed entirely.
|
|
74
|
+
await this.advance(task.id, now)
|
|
75
|
+
if (missed) continue
|
|
76
|
+
const lastTriggeredAt = task.execution.nextRunAt
|
|
77
|
+
await this.markTriggered(task.id, lastTriggeredAt)
|
|
78
|
+
await this.deps.execution.run(task.id, 'scheduled').catch(error => {
|
|
79
|
+
console.error('[dsh-taskboard] scheduled run failed:', error)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Recompute and persist the next run for one scheduled task. */
|
|
85
|
+
private async advance(taskId: string, now: number): Promise<void> {
|
|
86
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
87
|
+
const task = ledger.tasks.find(t => t.id === taskId)
|
|
88
|
+
if (task === undefined || task.execution.cron === undefined) return undefined
|
|
89
|
+
const match = parseCron(task.execution.cron)
|
|
90
|
+
const next = match === null ? undefined : nextCronTime(match, now) ?? undefined
|
|
91
|
+
if (next === undefined) return undefined
|
|
92
|
+
task.execution.nextRunAt = next
|
|
93
|
+
return [task]
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Record the trigger instant on the task. */
|
|
98
|
+
private async markTriggered(taskId: string, at: number | undefined): Promise<void> {
|
|
99
|
+
if (at === undefined) return
|
|
100
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
101
|
+
const task = ledger.tasks.find(t => t.id === taskId)
|
|
102
|
+
if (task === undefined) return undefined
|
|
103
|
+
task.execution.lastTriggeredAt = at
|
|
104
|
+
return [task]
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/host/sdk.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained replacements for the three @deepseek-ai runtime imports the
|
|
3
|
+
* host half used to take from npm-mirror SDK packages (dsh-home-paths,
|
|
4
|
+
* dsh-llm/brand, dsh-tools' defineTool).
|
|
5
|
+
*
|
|
6
|
+
* Why: a published copy must never resolve `@deepseek-ai/dsh-tools` from the
|
|
7
|
+
* profile's node_modules — an npm-mirror dsh-tools there shadows the
|
|
8
|
+
* CLI-internal build for the WHOLE base layer, and the agent loop's private
|
|
9
|
+
* scheduler symbol then misses (`Cannot read properties of undefined
|
|
10
|
+
* (reading 'prepare')` on every tool call). Everything here is a pure,
|
|
11
|
+
* structure-compatible reimplementation of the exact behavior we relied on:
|
|
12
|
+
*
|
|
13
|
+
* - `dshHomePath` mirrors `join(resolve(env.DSH_HOME ?? ~/.dsh), ...segments)`;
|
|
14
|
+
* - `MessageId` is the identity brand the SDK applies at runtime;
|
|
15
|
+
* - `defineTool` compiles our author-facing parameter specs into the same
|
|
16
|
+
* raw JSON-Schema subset the registry expects (object/properties/required/
|
|
17
|
+
* additionalProperties/scalars; the `json` node compiles to an
|
|
18
|
+
* annotation-only schema) and pre-validates model arguments the same way.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-taskboard/host/sdk
|
|
21
|
+
*/
|
|
22
|
+
import { homedir } from 'node:os'
|
|
23
|
+
import { join, resolve } from 'node:path'
|
|
24
|
+
|
|
25
|
+
/** The ledger file's parent: the DSH user home (DSH_HOME overrides). */
|
|
26
|
+
export function dshHomePath(...segments: string[]): string {
|
|
27
|
+
const override = process.env.DSH_HOME
|
|
28
|
+
const home = resolve(override !== undefined && override.length > 0 ? override : join(homedir(), '.dsh'))
|
|
29
|
+
return join(home, ...segments)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Identity brand — runtime no-op, exactly like the SDK's MessageId(). */
|
|
33
|
+
export function MessageId(id: string): string {
|
|
34
|
+
return id
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Author-facing scalar spec. */
|
|
38
|
+
interface ScalarSpec {
|
|
39
|
+
readonly type: 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
|
40
|
+
readonly description?: string
|
|
41
|
+
readonly enum?: readonly unknown[]
|
|
42
|
+
readonly const?: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Author-facing object spec (additionalProperties is mandatory). */
|
|
46
|
+
interface ObjectSpec {
|
|
47
|
+
readonly type: 'object'
|
|
48
|
+
readonly additionalProperties: boolean
|
|
49
|
+
readonly description?: string
|
|
50
|
+
readonly properties?: Readonly<Record<string, ValueSpec>>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Author-facing value spec. */
|
|
54
|
+
type ValueSpec = ScalarSpec | ObjectSpec | { readonly type: 'json' } | { readonly type: 'array'; readonly items?: ValueSpec; readonly description?: string }
|
|
55
|
+
|
|
56
|
+
/** Author-facing parameter entry (a value spec plus top-level required). */
|
|
57
|
+
type ParameterSpec = ValueSpec & { readonly required?: boolean }
|
|
58
|
+
|
|
59
|
+
/** Raw JSON-Schema subset node. */
|
|
60
|
+
type RawSchema = Record<string, unknown>
|
|
61
|
+
|
|
62
|
+
/** Compile one value spec to the raw subset (json → annotation-only). */
|
|
63
|
+
function compileValue(spec: ValueSpec): RawSchema {
|
|
64
|
+
const node: RawSchema = {}
|
|
65
|
+
const description = (spec as { description?: string }).description
|
|
66
|
+
if (typeof description === 'string' && description.length > 0) node.description = description
|
|
67
|
+
const type = (spec as { type?: string }).type
|
|
68
|
+
if (type === undefined || type === 'json') return node
|
|
69
|
+
if (type === 'object') {
|
|
70
|
+
const objectSpec = spec as ObjectSpec
|
|
71
|
+
node.type = 'object'
|
|
72
|
+
node.additionalProperties = objectSpec.additionalProperties
|
|
73
|
+
if (objectSpec.properties !== undefined) node.properties = compilePropertyMap(objectSpec.properties).properties
|
|
74
|
+
return node
|
|
75
|
+
}
|
|
76
|
+
if (type === 'array') {
|
|
77
|
+
node.type = 'array'
|
|
78
|
+
const items = (spec as { items?: ValueSpec }).items
|
|
79
|
+
if (items !== undefined) node.items = compileValue(items)
|
|
80
|
+
return node
|
|
81
|
+
}
|
|
82
|
+
node.type = type
|
|
83
|
+
const enumValues = (spec as ScalarSpec).enum
|
|
84
|
+
if (enumValues !== undefined) node.enum = [...enumValues]
|
|
85
|
+
const constValue = (spec as ScalarSpec).const
|
|
86
|
+
if (constValue !== undefined) node.const = constValue
|
|
87
|
+
return node
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Compile a property map: properties + collected required list. */
|
|
91
|
+
function compilePropertyMap(spec: Readonly<Record<string, ParameterSpec>>): { properties: Record<string, RawSchema>; required?: string[] } {
|
|
92
|
+
const properties: Record<string, RawSchema> = {}
|
|
93
|
+
const required: string[] = []
|
|
94
|
+
for (const [name, entry] of Object.entries(spec)) {
|
|
95
|
+
const { required: isRequired, ...valueSpec } = entry as ParameterSpec & Record<string, unknown>
|
|
96
|
+
properties[name] = compileValue(valueSpec as ValueSpec)
|
|
97
|
+
if (isRequired === true) required.push(name)
|
|
98
|
+
}
|
|
99
|
+
return required.length > 0 ? { properties, required } : { properties }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Does a JS value match a raw-subset scalar type? */
|
|
103
|
+
function matchesScalarType(value: unknown, type: string): boolean {
|
|
104
|
+
switch (type) {
|
|
105
|
+
case 'string': return typeof value === 'string'
|
|
106
|
+
case 'number': return typeof value === 'number'
|
|
107
|
+
case 'integer': return typeof value === 'number' && Number.isInteger(value)
|
|
108
|
+
case 'boolean': return typeof value === 'boolean'
|
|
109
|
+
case 'null': return value === null
|
|
110
|
+
default: return true
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Validate a value against the compiled subset; returns path-qualified violations. */
|
|
115
|
+
function validateValue(schema: RawSchema, value: unknown, path: string): string[] {
|
|
116
|
+
if (typeof schema.type !== 'string' || schema.type.length === 0) return []
|
|
117
|
+
if (schema.type === 'object') {
|
|
118
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return [`${path} must be an object`]
|
|
119
|
+
const violations: string[] = []
|
|
120
|
+
const present = value as Record<string, unknown>
|
|
121
|
+
for (const key of (schema.required as string[] | undefined) ?? []) {
|
|
122
|
+
if (!(key in present)) violations.push(`${path}.${key} is required`)
|
|
123
|
+
}
|
|
124
|
+
if (schema.additionalProperties === false) {
|
|
125
|
+
const known = new Set(Object.keys((schema.properties as Record<string, RawSchema> | undefined) ?? {}))
|
|
126
|
+
for (const key of Object.keys(present)) {
|
|
127
|
+
if (!known.has(key)) violations.push(`${path}.${key} is not a declared property`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const [key, child] of Object.entries((schema.properties as Record<string, RawSchema> | undefined) ?? {})) {
|
|
131
|
+
if (key in present) violations.push(...validateValue(child, present[key], `${path}.${key}`))
|
|
132
|
+
}
|
|
133
|
+
return violations
|
|
134
|
+
}
|
|
135
|
+
if (schema.type === 'array') {
|
|
136
|
+
if (!Array.isArray(value)) return [`${path} must be an array`]
|
|
137
|
+
const violations: string[] = []
|
|
138
|
+
const items = schema.items as RawSchema | undefined
|
|
139
|
+
if (items !== undefined) {
|
|
140
|
+
value.forEach((item, index) => { violations.push(...validateValue(items, item, `${path}[${index}]`)) })
|
|
141
|
+
}
|
|
142
|
+
return violations
|
|
143
|
+
}
|
|
144
|
+
return matchesScalarType(value, schema.type) ? [] : [`${path} must be ${schema.type}`]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Options shape we consume (a structural subset of the SDK's defineTool). */
|
|
148
|
+
export interface DefineToolOptions<A, V> {
|
|
149
|
+
readonly name: string
|
|
150
|
+
readonly description: string
|
|
151
|
+
readonly parameters: Readonly<Record<string, ParameterSpec>>
|
|
152
|
+
readonly output: {
|
|
153
|
+
readonly schema: { readonly type: 'json' }
|
|
154
|
+
render(args: A, value: V): Array<{ type: 'text'; text: string }>
|
|
155
|
+
}
|
|
156
|
+
execute(args: A, exec: unknown): Promise<V>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A registry-ready tool definition (structure-compatible with the SDK's). */
|
|
160
|
+
export interface ToolDefinition<A = unknown, V = unknown> {
|
|
161
|
+
readonly name: string
|
|
162
|
+
readonly description: string
|
|
163
|
+
readonly parameters: RawSchema
|
|
164
|
+
readonly output: {
|
|
165
|
+
readonly schema: RawSchema
|
|
166
|
+
render(args: A, value: V): Array<{ type: 'text'; text: string }>
|
|
167
|
+
}
|
|
168
|
+
execute(args: A, exec: unknown): Promise<V>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Define a first-party tool: compile the parameter spec, pre-validate
|
|
173
|
+
* arguments (message format matches the SDK's ToolArgsError), and pass
|
|
174
|
+
* through the execution.
|
|
175
|
+
*/
|
|
176
|
+
export function defineTool<A extends Record<string, unknown>, V>(options: DefineToolOptions<A, V>): ToolDefinition<A, V> {
|
|
177
|
+
const compiled = compilePropertyMap(options.parameters as Readonly<Record<string, ParameterSpec>>)
|
|
178
|
+
const parameters: RawSchema = { type: 'object', properties: compiled.properties }
|
|
179
|
+
if (compiled.required !== undefined) parameters.required = compiled.required
|
|
180
|
+
const userExecute = options.execute
|
|
181
|
+
return {
|
|
182
|
+
name: options.name,
|
|
183
|
+
description: options.description,
|
|
184
|
+
parameters,
|
|
185
|
+
output: {
|
|
186
|
+
// The SDK compiles the `json` node to an annotation-only schema.
|
|
187
|
+
schema: {},
|
|
188
|
+
render(args, value) {
|
|
189
|
+
return options.output.render(args, value)
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
async execute(args, exec) {
|
|
193
|
+
const violations = validateValue(parameters, args, 'arguments')
|
|
194
|
+
if (violations.length > 0) {
|
|
195
|
+
throw new Error(`Error: invalid arguments: ${violations.join('; ')}`)
|
|
196
|
+
}
|
|
197
|
+
return userExecute(args, exec)
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side task ledger: one JSON file under the DSH home, mutated through a
|
|
3
|
+
* serial write queue, published as immutable snapshots with a global
|
|
4
|
+
* monotonic revision. Change subscribers (P2: SSE route) observe every
|
|
5
|
+
* committed mutation.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-taskboard/host/store
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
LEDGER_SCHEMA_VERSION,
|
|
13
|
+
emptyLedger,
|
|
14
|
+
type TaskLedger,
|
|
15
|
+
type TaskRecord,
|
|
16
|
+
} from '../shared/protocol.ts'
|
|
17
|
+
|
|
18
|
+
/** One committed ledger mutation, handed to change subscribers. */
|
|
19
|
+
export interface LedgerChange {
|
|
20
|
+
/** Revision after the mutation. */
|
|
21
|
+
revision: number
|
|
22
|
+
/** The mutated tasks, if any (a comment purge may touch none). */
|
|
23
|
+
tasks: readonly TaskRecord[]
|
|
24
|
+
/** What kind of mutation this was (for SSE event naming later). */
|
|
25
|
+
kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Options for {@link TaskStore}. */
|
|
29
|
+
export interface TaskStoreOptions {
|
|
30
|
+
/** Absolute ledger file path. */
|
|
31
|
+
file: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The durable ledger. All mutations run through {@link mutate}, which:
|
|
36
|
+
* validates the resulting document, bumps the global revision, persists
|
|
37
|
+
* atomically (temp file + rename), and only then notifies subscribers.
|
|
38
|
+
*/
|
|
39
|
+
export class TaskStore {
|
|
40
|
+
private readonly file: string
|
|
41
|
+
private ledger: TaskLedger = emptyLedger()
|
|
42
|
+
private readonly subscribers = new Set<(change: LedgerChange) => void>()
|
|
43
|
+
private queue: Promise<unknown> = Promise.resolve()
|
|
44
|
+
private loaded = false
|
|
45
|
+
|
|
46
|
+
/** @param options - file location. */
|
|
47
|
+
constructor(options: TaskStoreOptions) {
|
|
48
|
+
this.file = options.file
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */
|
|
52
|
+
async load(): Promise<void> {
|
|
53
|
+
if (this.loaded) return
|
|
54
|
+
try {
|
|
55
|
+
const raw = await readFile(this.file, 'utf8')
|
|
56
|
+
const parsed = JSON.parse(raw) as TaskLedger
|
|
57
|
+
if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {
|
|
58
|
+
this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks: parsed.tasks }
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
62
|
+
if (code !== 'ENOENT') {
|
|
63
|
+
// Quarantine a corrupt ledger: rename it aside, start fresh. Never
|
|
64
|
+
// take the host down over ledger damage.
|
|
65
|
+
try {
|
|
66
|
+
await rename(this.file, `${this.file}.corrupt-${Date.now()}`)
|
|
67
|
+
} catch { /* best effort */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.loaded = true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The current immutable snapshot. */
|
|
74
|
+
snapshot(): TaskLedger {
|
|
75
|
+
return this.ledger
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Find a task by id. */
|
|
79
|
+
get(id: string): TaskRecord | undefined {
|
|
80
|
+
return this.ledger.tasks.find(t => t.id === id)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Subscribe to committed changes; returns the unsubscribe. */
|
|
84
|
+
subscribe(fn: (change: LedgerChange) => void): () => void {
|
|
85
|
+
this.subscribers.add(fn)
|
|
86
|
+
return () => this.subscribers.delete(fn)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run one mutation inside the serial queue. The mutator works on a
|
|
91
|
+
* structured clone; returning `undefined` aborts with no write.
|
|
92
|
+
* @param kind - change kind for subscribers.
|
|
93
|
+
* @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.
|
|
94
|
+
*/
|
|
95
|
+
async mutate(
|
|
96
|
+
kind: LedgerChange['kind'],
|
|
97
|
+
mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,
|
|
98
|
+
): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {
|
|
99
|
+
const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {
|
|
100
|
+
await this.load()
|
|
101
|
+
const draft: TaskLedger = structuredClone(this.ledger)
|
|
102
|
+
const changed = mutator(draft)
|
|
103
|
+
if (changed === undefined) {
|
|
104
|
+
return { ledger: this.ledger, changed: [] }
|
|
105
|
+
}
|
|
106
|
+
draft.revision += 1
|
|
107
|
+
const json = JSON.stringify(draft)
|
|
108
|
+
await persistAtomic(this.file, json)
|
|
109
|
+
this.ledger = draft
|
|
110
|
+
const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }
|
|
111
|
+
for (const fn of this.subscribers) {
|
|
112
|
+
try {
|
|
113
|
+
fn(change)
|
|
114
|
+
} catch { /* subscriber errors never abort the write */ }
|
|
115
|
+
}
|
|
116
|
+
return { ledger: draft, changed }
|
|
117
|
+
}
|
|
118
|
+
const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>
|
|
119
|
+
return result
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Persist the current ledger now (used after external reconciliation). */
|
|
123
|
+
async flush(kind: LedgerChange['kind'], changed: readonly TaskRecord[]): Promise<void> {
|
|
124
|
+
await this.mutate(kind, (ledger) => {
|
|
125
|
+
// replace tasks wholesale from the live snapshot objects
|
|
126
|
+
const byId = new Map(this.ledger.tasks.map(t => [t.id, t]))
|
|
127
|
+
ledger.tasks = ledger.tasks.map(t => byId.get(t.id) ?? t)
|
|
128
|
+
return [...changed]
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Atomic file persist: write temp, then rename over the target. */
|
|
134
|
+
async function persistAtomic(file: string, contents: string): Promise<void> {
|
|
135
|
+
await mkdir(dirname(file), { recursive: true })
|
|
136
|
+
const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)
|
|
137
|
+
await writeFile(temp, contents, 'utf8')
|
|
138
|
+
await rename(temp, file)
|
|
139
|
+
}
|