@xvzc/pi-tasks 1.0.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 +138 -0
- package/package.json +40 -0
- package/src/index.ts +353 -0
- package/src/store.ts +763 -0
- package/src/tasks-ui.ts +228 -0
- package/src/types.ts +57 -0
- package/src/widget.ts +341 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-main-session file-backed task store.
|
|
3
|
+
*
|
|
4
|
+
* File layout: `<cwd>/.pi/tasks/tasks-{sanitizedSessionId}.json`
|
|
5
|
+
* Envelope: `{ version: 1, nextId, tasks }`.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - IDs allocate monotonically from `nextId`; deleted IDs are never reused.
|
|
9
|
+
* - Writes are atomic (temp file + rename).
|
|
10
|
+
* - Dependency invariants are enforced on every mutation: references must
|
|
11
|
+
* exist, no self-reference, no cycles, and entering `in_progress` requires
|
|
12
|
+
* all dependencies to be completed.
|
|
13
|
+
* - `metadata` updates shallow-merge and `appendLog` appends a timestamped
|
|
14
|
+
* execution note. `assignee: null` / `color: null` remove those fields.
|
|
15
|
+
* Successful updates refresh `updatedAt` and never touch
|
|
16
|
+
* `createdAt`. Completed tasks may return to `pending`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
+
import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { isTaskStatus, type StoreData, type Task, type TaskLogEntry, type TaskStatus } from "./types.js";
|
|
25
|
+
|
|
26
|
+
export class TaskError extends Error {
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "TaskError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Deterministic, filename-safe, collision-resistant encoding for session IDs. */
|
|
34
|
+
export function sanitizeSessionId(sessionId: string): string {
|
|
35
|
+
const readable = sessionId.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 80) || "session";
|
|
36
|
+
const digest = createHash("sha256").update(sessionId).digest("hex");
|
|
37
|
+
return `${readable}-${digest}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolve the per-session store file for a working directory + session ID. */
|
|
41
|
+
export function taskFilePath(cwd: string, sessionId: string): string {
|
|
42
|
+
return join(cwd, ".pi", "tasks", `tasks-${sanitizeSessionId(sessionId)}.json`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function nowIso(): string {
|
|
46
|
+
return new Date().toISOString();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isUtcTimestamp(value: unknown): value is string {
|
|
54
|
+
if (typeof value !== "string") return false;
|
|
55
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(value);
|
|
56
|
+
if (!match) return false;
|
|
57
|
+
const date = new Date(value);
|
|
58
|
+
if (Number.isNaN(date.getTime())) return false;
|
|
59
|
+
return (
|
|
60
|
+
date.getUTCFullYear() === Number(match[1]) &&
|
|
61
|
+
date.getUTCMonth() + 1 === Number(match[2]) &&
|
|
62
|
+
date.getUTCDate() === Number(match[3]) &&
|
|
63
|
+
date.getUTCHours() === Number(match[4]) &&
|
|
64
|
+
date.getUTCMinutes() === Number(match[5]) &&
|
|
65
|
+
date.getUTCSeconds() === Number(match[6])
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const STORE_KEYS = new Set(["version", "nextId", "tasks", "totalActiveMs", "activeSince"]);
|
|
70
|
+
const TASK_KEYS = new Set([
|
|
71
|
+
"id",
|
|
72
|
+
"status",
|
|
73
|
+
"attempt",
|
|
74
|
+
"maxAttempts",
|
|
75
|
+
"createdAt",
|
|
76
|
+
"updatedAt",
|
|
77
|
+
"startedAt",
|
|
78
|
+
"tookMs",
|
|
79
|
+
"subject",
|
|
80
|
+
"description",
|
|
81
|
+
"assignee",
|
|
82
|
+
"color",
|
|
83
|
+
"blockedBy",
|
|
84
|
+
"metadata",
|
|
85
|
+
"log",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
function assertNoPrefix(value: Record<string, unknown>): void {
|
|
89
|
+
if ("prefix" in value) {
|
|
90
|
+
throw new TaskError("`prefix` was renamed to `assignee`; use `assignee`.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertOnlyKeys(record: Record<string, unknown>, allowed: Set<string>, label: string): void {
|
|
95
|
+
const unknown = Object.keys(record).filter((key) => !allowed.has(key));
|
|
96
|
+
if (unknown.length > 0) throw new TaskError(`${label} contains unknown key: ${unknown[0]}.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const LOG_ENTRY_KEYS = new Set(["timestamp", "message"]);
|
|
100
|
+
|
|
101
|
+
function parseTaskLog(raw: unknown): TaskLogEntry[] {
|
|
102
|
+
if (raw === undefined) return [];
|
|
103
|
+
if (!Array.isArray(raw)) throw new TaskError("Persisted task log must be an array.");
|
|
104
|
+
return raw.map((entry) => {
|
|
105
|
+
// Compatibility with the short-lived timestamp-prefixed string format.
|
|
106
|
+
if (typeof entry === "string") {
|
|
107
|
+
const separator = entry.indexOf(" ");
|
|
108
|
+
const timestamp = separator < 0 ? "" : entry.slice(0, separator);
|
|
109
|
+
const message = separator < 0 ? "" : entry.slice(separator + 1).trim();
|
|
110
|
+
if (!isUtcTimestamp(timestamp) || message.length === 0) {
|
|
111
|
+
throw new TaskError("Persisted task log entries must contain a valid timestamp and non-empty message.");
|
|
112
|
+
}
|
|
113
|
+
return { timestamp, message };
|
|
114
|
+
}
|
|
115
|
+
if (!isRecord(entry)) {
|
|
116
|
+
throw new TaskError("Persisted task log entries must be objects.");
|
|
117
|
+
}
|
|
118
|
+
assertOnlyKeys(entry, LOG_ENTRY_KEYS, "Persisted task log entry");
|
|
119
|
+
if (!isUtcTimestamp(entry.timestamp) || typeof entry.message !== "string" || entry.message.trim().length === 0) {
|
|
120
|
+
throw new TaskError("Persisted task log entries must contain a valid timestamp and non-empty message.");
|
|
121
|
+
}
|
|
122
|
+
return { timestamp: entry.timestamp, message: entry.message };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cloneLog(log: TaskLogEntry[]): TaskLogEntry[] {
|
|
127
|
+
return log.map((entry) => ({ ...entry }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseTask(raw: unknown): Task {
|
|
131
|
+
if (!isRecord(raw)) throw new TaskError("Each persisted task must be an object.");
|
|
132
|
+
assertOnlyKeys(raw, TASK_KEYS, "Persisted task");
|
|
133
|
+
assertValidId(raw.id as number);
|
|
134
|
+
if (typeof raw.maxAttempts !== "number" || !Number.isSafeInteger(raw.maxAttempts) || raw.maxAttempts <= 0) {
|
|
135
|
+
throw new TaskError("Persisted task maxAttempts must be a positive safe integer.");
|
|
136
|
+
}
|
|
137
|
+
if (typeof raw.attempt !== "number" || !Number.isSafeInteger(raw.attempt) || raw.attempt < 0) {
|
|
138
|
+
throw new TaskError("Persisted task attempt must be a non-negative safe integer.");
|
|
139
|
+
}
|
|
140
|
+
if ((raw.attempt as number) > (raw.maxAttempts as number)) {
|
|
141
|
+
throw new TaskError("Persisted task attempt must not exceed maxAttempts.");
|
|
142
|
+
}
|
|
143
|
+
if (typeof raw.subject !== "string" || raw.subject.trim().length === 0) {
|
|
144
|
+
throw new TaskError("Persisted task subject must be a non-empty string.");
|
|
145
|
+
}
|
|
146
|
+
if (typeof raw.description !== "string") throw new TaskError("Persisted task description must be a string.");
|
|
147
|
+
if (!isTaskStatus(raw.status)) throw new TaskError(`Invalid persisted task status: ${String(raw.status)}.`);
|
|
148
|
+
if (!Array.isArray(raw.blockedBy)) throw new TaskError("Persisted task blockedBy must be an array of task ids.");
|
|
149
|
+
assertIdList(raw.blockedBy as number[]);
|
|
150
|
+
if (!isRecord(raw.metadata)) throw new TaskError("Persisted task metadata must be an object.");
|
|
151
|
+
const log = parseTaskLog(raw.log);
|
|
152
|
+
if (!isUtcTimestamp(raw.createdAt) || !isUtcTimestamp(raw.updatedAt)) {
|
|
153
|
+
throw new TaskError("Persisted task timestamps must be valid ISO 8601 UTC strings.");
|
|
154
|
+
}
|
|
155
|
+
if ("assignee" in raw && typeof raw.assignee !== "string") {
|
|
156
|
+
throw new TaskError("Persisted task assignee must be a string.");
|
|
157
|
+
}
|
|
158
|
+
if ("color" in raw && typeof raw.color !== "string") {
|
|
159
|
+
throw new TaskError("Persisted task color must be a string.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const task: Task = {
|
|
163
|
+
id: raw.id as number,
|
|
164
|
+
subject: raw.subject,
|
|
165
|
+
description: raw.description,
|
|
166
|
+
status: raw.status,
|
|
167
|
+
attempt: raw.attempt as number,
|
|
168
|
+
maxAttempts: raw.maxAttempts as number,
|
|
169
|
+
blockedBy: [...(raw.blockedBy as number[])],
|
|
170
|
+
metadata: { ...raw.metadata },
|
|
171
|
+
log,
|
|
172
|
+
createdAt: raw.createdAt,
|
|
173
|
+
updatedAt: raw.updatedAt,
|
|
174
|
+
};
|
|
175
|
+
if (typeof raw.assignee === "string") task.assignee = raw.assignee;
|
|
176
|
+
if (typeof raw.color === "string") task.color = raw.color;
|
|
177
|
+
if ("startedAt" in raw && raw.startedAt !== undefined) {
|
|
178
|
+
if (!isUtcTimestamp(raw.startedAt)) {
|
|
179
|
+
throw new TaskError("Persisted task startedAt must be a valid ISO 8601 UTC string.");
|
|
180
|
+
}
|
|
181
|
+
task.startedAt = raw.startedAt;
|
|
182
|
+
}
|
|
183
|
+
if ("tookMs" in raw && raw.tookMs !== undefined) {
|
|
184
|
+
if (typeof raw.tookMs !== "number" || !Number.isSafeInteger(raw.tookMs) || raw.tookMs < 0) {
|
|
185
|
+
throw new TaskError("Persisted task tookMs must be a non-negative safe integer.");
|
|
186
|
+
}
|
|
187
|
+
task.tookMs = raw.tookMs;
|
|
188
|
+
}
|
|
189
|
+
return task;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function errorCode(error: unknown): string | undefined {
|
|
193
|
+
return isRecord(error) && typeof error.code === "string" ? error.code : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface TaskCreateInput {
|
|
197
|
+
subject: string;
|
|
198
|
+
description: string;
|
|
199
|
+
assignee?: string;
|
|
200
|
+
color?: string;
|
|
201
|
+
blockedBy?: number[];
|
|
202
|
+
metadata?: Record<string, unknown>;
|
|
203
|
+
/** Per-task attempt cap. Defaults to 9. Must be a positive safe integer. */
|
|
204
|
+
maxAttempts?: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface TaskPatch {
|
|
208
|
+
subject?: string;
|
|
209
|
+
description?: string;
|
|
210
|
+
/** `null` removes the field. */
|
|
211
|
+
assignee?: string | null;
|
|
212
|
+
/** `null` removes the field. */
|
|
213
|
+
color?: string | null;
|
|
214
|
+
status?: TaskStatus;
|
|
215
|
+
/** Full replacement of the dependency list. */
|
|
216
|
+
blockedBy?: number[];
|
|
217
|
+
/** Shallow-merged into the existing metadata. */
|
|
218
|
+
metadata?: Record<string, unknown>;
|
|
219
|
+
/** Append one non-empty execution note; the store prefixes its timestamp. */
|
|
220
|
+
appendLog?: string;
|
|
221
|
+
/** Never present: `attempt` and `maxAttempts` are immutable and excluded from updates. */
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export type TaskStoreWriter = (filePath: string, data: StoreData) => Promise<void>;
|
|
225
|
+
|
|
226
|
+
async function writeStoreData(filePath: string, data: StoreData): Promise<void> {
|
|
227
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
228
|
+
const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
229
|
+
try {
|
|
230
|
+
await writeFile(tmpPath, JSON.stringify(data, null, 2));
|
|
231
|
+
await rename(tmpPath, filePath);
|
|
232
|
+
} finally {
|
|
233
|
+
await unlink(tmpPath).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export class TaskStore {
|
|
238
|
+
private tasks = new Map<number, Task>();
|
|
239
|
+
private nextId = 1;
|
|
240
|
+
private totalActiveMs = 0;
|
|
241
|
+
private activeSince: string | undefined;
|
|
242
|
+
|
|
243
|
+
constructor(readonly filePath: string, private readonly writer: TaskStoreWriter = writeStoreData) {}
|
|
244
|
+
|
|
245
|
+
/** Wall-clock union timing: finished active milliseconds plus the running period (if any). */
|
|
246
|
+
activeTiming(): { totalActiveMs: number; activeSince?: string } {
|
|
247
|
+
return this.activeSince === undefined
|
|
248
|
+
? { totalActiveMs: this.totalActiveMs }
|
|
249
|
+
: { totalActiveMs: this.totalActiveMs, activeSince: this.activeSince };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Load and fully validate a store from disk. A missing file yields an empty store. */
|
|
253
|
+
static async load(filePath: string, writer: TaskStoreWriter = writeStoreData): Promise<TaskStore> {
|
|
254
|
+
const store = new TaskStore(filePath, writer);
|
|
255
|
+
let raw: string;
|
|
256
|
+
try {
|
|
257
|
+
raw = await readFile(filePath, "utf8");
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (errorCode(error) === "ENOENT") return store;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const data: unknown = JSON.parse(raw);
|
|
265
|
+
if (!isRecord(data)) throw new TaskError("Persisted task store must be an object.");
|
|
266
|
+
assertOnlyKeys(data, STORE_KEYS, "Persisted task store");
|
|
267
|
+
if (data.version !== 1) throw new TaskError(`Unsupported persisted task store version: ${String(data.version)}.`);
|
|
268
|
+
if (!Array.isArray(data.tasks)) throw new TaskError("Persisted task store tasks must be an array.");
|
|
269
|
+
if (
|
|
270
|
+
typeof data.nextId !== "number" ||
|
|
271
|
+
!Number.isSafeInteger(data.nextId) ||
|
|
272
|
+
data.nextId <= 0
|
|
273
|
+
) {
|
|
274
|
+
throw new TaskError("Persisted task store nextId must be a positive safe integer.");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const tasks = new Map<number, Task>();
|
|
278
|
+
for (const entry of data.tasks) {
|
|
279
|
+
const task = parseTask(entry);
|
|
280
|
+
if (tasks.has(task.id)) throw new TaskError(`Duplicate persisted task id: #${task.id}.`);
|
|
281
|
+
tasks.set(task.id, task);
|
|
282
|
+
}
|
|
283
|
+
const maxId = Math.max(0, ...tasks.keys());
|
|
284
|
+
if (data.nextId <= maxId) {
|
|
285
|
+
throw new TaskError(`Persisted task store nextId must be greater than every task id.`);
|
|
286
|
+
}
|
|
287
|
+
validateDependencies(tasks);
|
|
288
|
+
|
|
289
|
+
let totalActiveMs = 0;
|
|
290
|
+
let activeSince: string | undefined;
|
|
291
|
+
if ("totalActiveMs" in data && data.totalActiveMs !== undefined) {
|
|
292
|
+
if (typeof data.totalActiveMs !== "number" || !Number.isSafeInteger(data.totalActiveMs) || data.totalActiveMs < 0) {
|
|
293
|
+
throw new TaskError("Persisted task store totalActiveMs must be a non-negative safe integer.");
|
|
294
|
+
}
|
|
295
|
+
totalActiveMs = data.totalActiveMs;
|
|
296
|
+
}
|
|
297
|
+
if ("activeSince" in data && data.activeSince !== undefined) {
|
|
298
|
+
if (!isUtcTimestamp(data.activeSince)) {
|
|
299
|
+
throw new TaskError("Persisted task store activeSince must be a valid ISO 8601 UTC string.");
|
|
300
|
+
}
|
|
301
|
+
activeSince = data.activeSince;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Sensible reload reconciliation so timers neither reset nor count
|
|
305
|
+
// stopped periods after restart. Persisted per-task `startedAt` and
|
|
306
|
+
// the global `activeSince` are wall-clock values: preserving them
|
|
307
|
+
// keeps the running attempt and global totals continuous across a
|
|
308
|
+
// restart (downtime while tasks remain `in_progress` counts as
|
|
309
|
+
// active, matching the pre-restart wall clock). Legacy files without
|
|
310
|
+
// timing fields start counting from load time instead of inventing
|
|
311
|
+
// history (each `in_progress` task without `startedAt` starts its
|
|
312
|
+
// attempt at the load timestamp, matching the global `activeSince`
|
|
313
|
+
// load-time behavior), and corrupt states (active marker without an
|
|
314
|
+
// active task, or a `startedAt` on a non-active task) are cleared
|
|
315
|
+
// without adding unknown time.
|
|
316
|
+
const loadNow = nowIso();
|
|
317
|
+
const hasActive = [...tasks.values()].some((task) => task.status === "in_progress");
|
|
318
|
+
for (const task of tasks.values()) {
|
|
319
|
+
if (task.status !== "in_progress") {
|
|
320
|
+
if (task.startedAt !== undefined) delete task.startedAt;
|
|
321
|
+
} else if (task.startedAt === undefined) {
|
|
322
|
+
task.startedAt = loadNow;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (hasActive && activeSince === undefined) {
|
|
326
|
+
activeSince = loadNow;
|
|
327
|
+
} else if (!hasActive && activeSince !== undefined) {
|
|
328
|
+
activeSince = undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
store.tasks = tasks;
|
|
332
|
+
store.nextId = data.nextId;
|
|
333
|
+
store.totalActiveMs = totalActiveMs;
|
|
334
|
+
store.activeSince = activeSince;
|
|
335
|
+
return store;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (error instanceof TaskError) throw error;
|
|
338
|
+
throw new TaskError(`Invalid persisted task store: ${error instanceof Error ? error.message : String(error)}.`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Atomic write (temp file + rename). Always writes, even when empty, so `nextId` survives. */
|
|
343
|
+
async save(): Promise<void> {
|
|
344
|
+
await this.writeData(this.tasks, this.nextId, this.totalActiveMs, this.activeSince);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private async writeData(tasks: Map<number, Task>, nextId: number, totalActiveMs: number, activeSince: string | undefined): Promise<void> {
|
|
348
|
+
const data: StoreData = { version: 1, nextId, tasks: [...tasks.values()], totalActiveMs };
|
|
349
|
+
if (activeSince !== undefined) data.activeSince = activeSince;
|
|
350
|
+
await this.writer(this.filePath, data);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
existsOnDisk(): boolean {
|
|
354
|
+
return existsSync(this.filePath);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
list(status?: TaskStatus): Task[] {
|
|
358
|
+
const tasks = [...this.tasks.values()].sort((a, b) => a.id - b.id);
|
|
359
|
+
const filtered = status === undefined ? tasks : tasks.filter((task) => task.status === status);
|
|
360
|
+
return filtered.map((task) => ({ ...task, blockedBy: [...task.blockedBy], metadata: { ...task.metadata }, log: cloneLog(task.log) }));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
get(id: number): Task | undefined {
|
|
364
|
+
assertValidId(id);
|
|
365
|
+
const task = this.tasks.get(id);
|
|
366
|
+
return task === undefined ? undefined : { ...task, blockedBy: [...task.blockedBy], metadata: { ...task.metadata }, log: cloneLog(task.log) };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async create(input: TaskCreateInput): Promise<Task> {
|
|
370
|
+
assertNoPrefix(input as unknown as Record<string, unknown>);
|
|
371
|
+
if (typeof input.subject !== "string" || input.subject.trim().length === 0) {
|
|
372
|
+
throw new TaskError("subject must be a non-empty string.");
|
|
373
|
+
}
|
|
374
|
+
const description = input.description;
|
|
375
|
+
if (typeof description !== "string") throw new TaskError("description must be a string.");
|
|
376
|
+
if (input.assignee !== undefined && typeof input.assignee !== "string") {
|
|
377
|
+
throw new TaskError("assignee must be a string.");
|
|
378
|
+
}
|
|
379
|
+
if (input.color !== undefined && typeof input.color !== "string") {
|
|
380
|
+
throw new TaskError("color must be a string.");
|
|
381
|
+
}
|
|
382
|
+
const blockedBy = input.blockedBy ?? [];
|
|
383
|
+
assertIdList(blockedBy);
|
|
384
|
+
const metadata = input.metadata ?? {};
|
|
385
|
+
if (!isRecord(metadata)) throw new TaskError("metadata must be an object.");
|
|
386
|
+
const maxAttempts = input.maxAttempts ?? 9;
|
|
387
|
+
assertValidMaxAttempts(maxAttempts);
|
|
388
|
+
|
|
389
|
+
// An all-completed store resets atomically: validate the new task against
|
|
390
|
+
// a fresh state and commit the replacement envelope (new task #1) with a
|
|
391
|
+
// single temp-file+rename write. Validation or persistence failure leaves
|
|
392
|
+
// the old completed file and in-memory state untouched.
|
|
393
|
+
if (this.tasks.size > 0 && [...this.tasks.values()].every((task) => task.status === "completed")) {
|
|
394
|
+
return this.createReset(input, { description, blockedBy, metadata, maxAttempts });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const id = this.nextId;
|
|
398
|
+
if (id === Number.MAX_SAFE_INTEGER) {
|
|
399
|
+
throw new TaskError("Task ID space is exhausted; no further tasks can be created.");
|
|
400
|
+
}
|
|
401
|
+
for (const depId of blockedBy) {
|
|
402
|
+
if (depId === id) throw new TaskError(`Task #${id} cannot depend on itself.`);
|
|
403
|
+
if (!this.tasks.has(depId)) throw new TaskError(`Task #${depId} does not exist.`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const now = nowIso();
|
|
407
|
+
const task: Task = {
|
|
408
|
+
id,
|
|
409
|
+
subject: input.subject,
|
|
410
|
+
description,
|
|
411
|
+
status: "pending",
|
|
412
|
+
attempt: 0,
|
|
413
|
+
maxAttempts,
|
|
414
|
+
blockedBy: [...blockedBy],
|
|
415
|
+
metadata: { ...metadata },
|
|
416
|
+
log: [],
|
|
417
|
+
createdAt: now,
|
|
418
|
+
updatedAt: now,
|
|
419
|
+
};
|
|
420
|
+
if (input.assignee !== undefined) task.assignee = input.assignee;
|
|
421
|
+
if (input.color !== undefined) task.color = input.color;
|
|
422
|
+
const candidateTasks = cloneTasks(this.tasks);
|
|
423
|
+
candidateTasks.set(id, task);
|
|
424
|
+
const candidateNextId = id + 1;
|
|
425
|
+
// A pending create never changes the active set, so global timing passes through.
|
|
426
|
+
await this.writeData(candidateTasks, candidateNextId, this.totalActiveMs, this.activeSince);
|
|
427
|
+
this.tasks = candidateTasks;
|
|
428
|
+
this.nextId = candidateNextId;
|
|
429
|
+
return { ...task, blockedBy: [...task.blockedBy], metadata: { ...task.metadata }, log: cloneLog(task.log) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async createReset(
|
|
433
|
+
input: TaskCreateInput,
|
|
434
|
+
validated: { description: string; blockedBy: number[]; metadata: Record<string, unknown>; maxAttempts: number },
|
|
435
|
+
): Promise<Task> {
|
|
436
|
+
const id = 1;
|
|
437
|
+
for (const depId of validated.blockedBy) {
|
|
438
|
+
if (depId === id) throw new TaskError(`Task #${id} cannot depend on itself.`);
|
|
439
|
+
throw new TaskError(`Task #${depId} does not exist.`);
|
|
440
|
+
}
|
|
441
|
+
const now = nowIso();
|
|
442
|
+
const task: Task = {
|
|
443
|
+
id,
|
|
444
|
+
subject: input.subject,
|
|
445
|
+
description: validated.description,
|
|
446
|
+
status: "pending",
|
|
447
|
+
attempt: 0,
|
|
448
|
+
maxAttempts: validated.maxAttempts,
|
|
449
|
+
blockedBy: [],
|
|
450
|
+
metadata: { ...validated.metadata },
|
|
451
|
+
log: [],
|
|
452
|
+
createdAt: now,
|
|
453
|
+
updatedAt: now,
|
|
454
|
+
};
|
|
455
|
+
if (input.assignee !== undefined) task.assignee = input.assignee;
|
|
456
|
+
if (input.color !== undefined) task.color = input.color;
|
|
457
|
+
const candidateTasks = new Map<number, Task>([[id, task]]);
|
|
458
|
+
const candidateNextId = 2;
|
|
459
|
+
validateDependencies(candidateTasks);
|
|
460
|
+
// A reset starts a fresh list: the previous all-completed run's union
|
|
461
|
+
// time stays on disk history only via the replaced file; the new list
|
|
462
|
+
// accumulates from zero.
|
|
463
|
+
await this.writeData(candidateTasks, candidateNextId, 0, undefined);
|
|
464
|
+
this.tasks = candidateTasks;
|
|
465
|
+
this.nextId = candidateNextId;
|
|
466
|
+
this.totalActiveMs = 0;
|
|
467
|
+
this.activeSince = undefined;
|
|
468
|
+
return { ...task, blockedBy: [...task.blockedBy], metadata: { ...task.metadata }, log: cloneLog(task.log) };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async update(id: number, patch: TaskPatch): Promise<Task> {
|
|
472
|
+
assertValidId(id);
|
|
473
|
+
assertNoPrefix(patch as unknown as Record<string, unknown>);
|
|
474
|
+
if ("attempt" in (patch as Record<string, unknown>)) {
|
|
475
|
+
throw new TaskError("attempt cannot be updated; it increments only on entry into in_progress.");
|
|
476
|
+
}
|
|
477
|
+
if ("maxAttempts" in (patch as Record<string, unknown>)) {
|
|
478
|
+
throw new TaskError("maxAttempts cannot be updated; it is set once at creation.");
|
|
479
|
+
}
|
|
480
|
+
const current = this.tasks.get(id);
|
|
481
|
+
if (!current) throw new TaskError(`Task #${id} does not exist.`);
|
|
482
|
+
|
|
483
|
+
const candidateTasks = cloneTasks(this.tasks);
|
|
484
|
+
const task = candidateTasks.get(id) as Task;
|
|
485
|
+
|
|
486
|
+
if (patch.subject !== undefined) {
|
|
487
|
+
if (typeof patch.subject !== "string" || patch.subject.trim().length === 0) {
|
|
488
|
+
throw new TaskError("subject must be a non-empty string.");
|
|
489
|
+
}
|
|
490
|
+
task.subject = patch.subject;
|
|
491
|
+
}
|
|
492
|
+
if (patch.description !== undefined) {
|
|
493
|
+
if (typeof patch.description !== "string") throw new TaskError("description must be a string.");
|
|
494
|
+
task.description = patch.description;
|
|
495
|
+
}
|
|
496
|
+
if (patch.assignee !== undefined) {
|
|
497
|
+
if (patch.assignee !== null && typeof patch.assignee !== "string") {
|
|
498
|
+
throw new TaskError("assignee must be a string or null.");
|
|
499
|
+
}
|
|
500
|
+
if (patch.assignee === null) delete task.assignee;
|
|
501
|
+
else task.assignee = patch.assignee;
|
|
502
|
+
}
|
|
503
|
+
if (patch.color !== undefined) {
|
|
504
|
+
if (patch.color !== null && typeof patch.color !== "string") {
|
|
505
|
+
throw new TaskError("color must be a string or null.");
|
|
506
|
+
}
|
|
507
|
+
if (patch.color === null) delete task.color;
|
|
508
|
+
else task.color = patch.color;
|
|
509
|
+
}
|
|
510
|
+
if (patch.blockedBy !== undefined) {
|
|
511
|
+
assertIdList(patch.blockedBy);
|
|
512
|
+
task.blockedBy = [...patch.blockedBy];
|
|
513
|
+
}
|
|
514
|
+
if (patch.metadata !== undefined) {
|
|
515
|
+
if (!isRecord(patch.metadata)) throw new TaskError("metadata must be an object.");
|
|
516
|
+
task.metadata = { ...task.metadata, ...patch.metadata };
|
|
517
|
+
}
|
|
518
|
+
if (patch.appendLog !== undefined && (typeof patch.appendLog !== "string" || patch.appendLog.trim().length === 0)) {
|
|
519
|
+
throw new TaskError("appendLog must be a non-empty string.");
|
|
520
|
+
}
|
|
521
|
+
if (patch.status !== undefined) {
|
|
522
|
+
if (!isTaskStatus(patch.status)) throw new TaskError(`Invalid status: ${String(patch.status)}.`);
|
|
523
|
+
if (patch.status === "in_progress" && current.status !== "in_progress") {
|
|
524
|
+
if (current.attempt >= current.maxAttempts) {
|
|
525
|
+
throw new TaskError(`Task #${id} has reached the maximum number of attempts (${current.maxAttempts}).`);
|
|
526
|
+
}
|
|
527
|
+
task.attempt = current.attempt + 1;
|
|
528
|
+
}
|
|
529
|
+
task.status = patch.status;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const nowMs = Date.now();
|
|
533
|
+
const now = new Date(nowMs).toISOString();
|
|
534
|
+
if (patch.appendLog !== undefined) {
|
|
535
|
+
task.log.push({ timestamp: now, message: patch.appendLog.trim() });
|
|
536
|
+
}
|
|
537
|
+
// Per-attempt timing: the timer starts at zero only on a real
|
|
538
|
+
// non-`in_progress` -> `in_progress` transition and is preserved by
|
|
539
|
+
// `in_progress` -> `in_progress` updates. Completion freezes the
|
|
540
|
+
// attempt duration into `tookMs`; rework clears it so the new attempt
|
|
541
|
+
// starts at zero and the next completion overwrites it.
|
|
542
|
+
if (patch.status === "in_progress" && current.status !== "in_progress") {
|
|
543
|
+
task.startedAt = now;
|
|
544
|
+
delete task.tookMs;
|
|
545
|
+
} else if (patch.status === "completed" && current.status !== "completed") {
|
|
546
|
+
if (current.startedAt !== undefined) {
|
|
547
|
+
task.tookMs = Math.max(0, nowMs - Date.parse(current.startedAt));
|
|
548
|
+
} else if (current.tookMs === undefined) {
|
|
549
|
+
task.tookMs = 0;
|
|
550
|
+
}
|
|
551
|
+
delete task.startedAt;
|
|
552
|
+
} else if (current.status === "in_progress" && patch.status !== undefined && patch.status !== "in_progress" && patch.status !== "completed") {
|
|
553
|
+
delete task.startedAt;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
task.updatedAt = now;
|
|
557
|
+
validateDependencies(candidateTasks);
|
|
558
|
+
const candidateTiming = advanceGlobalTiming(this.tasks, candidateTasks, this.totalActiveMs, this.activeSince, nowMs, now);
|
|
559
|
+
await this.writeData(candidateTasks, this.nextId, candidateTiming.totalActiveMs, candidateTiming.activeSince);
|
|
560
|
+
this.tasks = candidateTasks;
|
|
561
|
+
this.totalActiveMs = candidateTiming.totalActiveMs;
|
|
562
|
+
this.activeSince = candidateTiming.activeSince;
|
|
563
|
+
return { ...task, blockedBy: [...task.blockedBy], metadata: { ...task.metadata }, log: cloneLog(task.log) };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async delete(id: number): Promise<void> {
|
|
567
|
+
assertValidId(id);
|
|
568
|
+
if (!this.tasks.has(id)) throw new TaskError(`Task #${id} does not exist.`);
|
|
569
|
+
const referencers = [...this.tasks.values()]
|
|
570
|
+
.filter((task) => task.blockedBy.includes(id))
|
|
571
|
+
.map((task) => `#${task.id}`)
|
|
572
|
+
.sort();
|
|
573
|
+
if (referencers.length > 0) {
|
|
574
|
+
throw new TaskError(`Task #${id} cannot be deleted: referenced by ${referencers.join(", ")}.`);
|
|
575
|
+
}
|
|
576
|
+
const candidateTasks = cloneTasks(this.tasks);
|
|
577
|
+
candidateTasks.delete(id);
|
|
578
|
+
const nowMs = Date.now();
|
|
579
|
+
const now = new Date(nowMs).toISOString();
|
|
580
|
+
// Empty-list reset: deleting the final task discards any running slice
|
|
581
|
+
// and accumulated union time so a fresh list starts from zero. IDs and
|
|
582
|
+
// nextId are preserved. Non-empty deletions keep union accounting.
|
|
583
|
+
const candidateTiming =
|
|
584
|
+
candidateTasks.size === 0
|
|
585
|
+
? { totalActiveMs: 0, activeSince: undefined as string | undefined }
|
|
586
|
+
: advanceGlobalTiming(this.tasks, candidateTasks, this.totalActiveMs, this.activeSince, nowMs, now);
|
|
587
|
+
await this.writeData(candidateTasks, this.nextId, candidateTiming.totalActiveMs, candidateTiming.activeSince);
|
|
588
|
+
this.tasks = candidateTasks;
|
|
589
|
+
this.totalActiveMs = candidateTiming.totalActiveMs;
|
|
590
|
+
this.activeSince = candidateTiming.activeSince;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Remove every completed task in a single atomic write. References to the
|
|
595
|
+
* removed IDs are stripped from remaining tasks' `blockedBy` arrays so
|
|
596
|
+
* dependency invariants stay valid. Returns the removed IDs (sorted).
|
|
597
|
+
* No write occurs when nothing is completed. Persistence failure leaves
|
|
598
|
+
* in-memory state unchanged.
|
|
599
|
+
*/
|
|
600
|
+
async clearCompleted(): Promise<number[]> {
|
|
601
|
+
const removed = [...this.tasks.values()]
|
|
602
|
+
.filter((task) => task.status === "completed")
|
|
603
|
+
.map((task) => task.id)
|
|
604
|
+
.sort((a, b) => a - b);
|
|
605
|
+
if (removed.length === 0) return [];
|
|
606
|
+
const removedSet = new Set(removed);
|
|
607
|
+
const candidateTasks = new Map<number, Task>();
|
|
608
|
+
for (const [id, task] of this.tasks) {
|
|
609
|
+
if (removedSet.has(id)) continue;
|
|
610
|
+
candidateTasks.set(id, {
|
|
611
|
+
...task,
|
|
612
|
+
blockedBy: task.blockedBy.filter((depId) => !removedSet.has(depId)),
|
|
613
|
+
metadata: { ...task.metadata },
|
|
614
|
+
log: cloneLog(task.log),
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
validateDependencies(candidateTasks);
|
|
618
|
+
const nowMs = Date.now();
|
|
619
|
+
const now = new Date(nowMs).toISOString();
|
|
620
|
+
// Empty-list reset mirrors delete: a fresh list starts from zero.
|
|
621
|
+
// Otherwise only completed tasks left, so the active set is unchanged.
|
|
622
|
+
const candidateTiming =
|
|
623
|
+
candidateTasks.size === 0
|
|
624
|
+
? { totalActiveMs: 0, activeSince: undefined as string | undefined }
|
|
625
|
+
: advanceGlobalTiming(this.tasks, candidateTasks, this.totalActiveMs, this.activeSince, nowMs, now);
|
|
626
|
+
await this.writeData(candidateTasks, this.nextId, candidateTiming.totalActiveMs, candidateTiming.activeSince);
|
|
627
|
+
this.tasks = candidateTasks;
|
|
628
|
+
this.totalActiveMs = candidateTiming.totalActiveMs;
|
|
629
|
+
this.activeSince = candidateTiming.activeSince;
|
|
630
|
+
return removed;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Remove every task in a single atomic write, reset active timing, and
|
|
635
|
+
* preserve `nextId`. Returns the removed count. No write occurs when
|
|
636
|
+
* already empty. Persistence failure leaves in-memory state unchanged.
|
|
637
|
+
*/
|
|
638
|
+
async clearAll(): Promise<number> {
|
|
639
|
+
if (this.tasks.size === 0) return 0;
|
|
640
|
+
const count = this.tasks.size;
|
|
641
|
+
await this.writeData(new Map<number, Task>(), this.nextId, 0, undefined);
|
|
642
|
+
this.tasks = new Map<number, Task>();
|
|
643
|
+
this.totalActiveMs = 0;
|
|
644
|
+
this.activeSince = undefined;
|
|
645
|
+
return count;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function countActive(tasks: Map<number, Task>): number {
|
|
651
|
+
let count = 0;
|
|
652
|
+
for (const task of tasks.values()) {
|
|
653
|
+
if (task.status === "in_progress") count += 1;
|
|
654
|
+
}
|
|
655
|
+
return count;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Wall-clock union accounting for global active time. The clock runs while
|
|
660
|
+
* at least one task is `in_progress`, stops when none are (adding the
|
|
661
|
+
* finished slice to the total without double-counting concurrency), and
|
|
662
|
+
* resumes from the accumulated value when work restarts. Rework counts as
|
|
663
|
+
* ordinary active time.
|
|
664
|
+
*/
|
|
665
|
+
function advanceGlobalTiming(
|
|
666
|
+
before: Map<number, Task>,
|
|
667
|
+
after: Map<number, Task>,
|
|
668
|
+
totalActiveMs: number,
|
|
669
|
+
activeSince: string | undefined,
|
|
670
|
+
nowMs: number,
|
|
671
|
+
nowIsoValue: string,
|
|
672
|
+
): { totalActiveMs: number; activeSince: string | undefined } {
|
|
673
|
+
const beforeActive = countActive(before);
|
|
674
|
+
const afterActive = countActive(after);
|
|
675
|
+
if (beforeActive > 0 && afterActive > 0) {
|
|
676
|
+
return { totalActiveMs, activeSince: activeSince ?? nowIsoValue };
|
|
677
|
+
}
|
|
678
|
+
if (beforeActive > 0 && afterActive === 0) {
|
|
679
|
+
const startMs = activeSince === undefined ? nowMs : Date.parse(activeSince);
|
|
680
|
+
const slice = Number.isFinite(startMs) ? Math.max(0, nowMs - startMs) : 0;
|
|
681
|
+
return { totalActiveMs: totalActiveMs + slice, activeSince: undefined };
|
|
682
|
+
}
|
|
683
|
+
if (beforeActive === 0 && afterActive > 0) {
|
|
684
|
+
return { totalActiveMs, activeSince: nowIsoValue };
|
|
685
|
+
}
|
|
686
|
+
return { totalActiveMs, activeSince };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function cloneTasks(tasks: Map<number, Task>): Map<number, Task> {
|
|
690
|
+
return new Map(
|
|
691
|
+
[...tasks].map(([id, task]) => [
|
|
692
|
+
id,
|
|
693
|
+
{ ...task, blockedBy: [...task.blockedBy], metadata: { ...task.metadata }, log: cloneLog(task.log) },
|
|
694
|
+
]),
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function validateDependencies(tasks: Map<number, Task>): void {
|
|
699
|
+
for (const task of tasks.values()) {
|
|
700
|
+
for (const depId of task.blockedBy) {
|
|
701
|
+
if (depId === task.id) throw new TaskError(`Task #${task.id} cannot depend on itself.`);
|
|
702
|
+
if (!tasks.has(depId)) throw new TaskError(`Task #${depId} does not exist.`);
|
|
703
|
+
}
|
|
704
|
+
if (task.status === "in_progress") {
|
|
705
|
+
const open = task.blockedBy.filter((depId) => tasks.get(depId)?.status !== "completed");
|
|
706
|
+
if (open.length > 0) {
|
|
707
|
+
throw new TaskError(
|
|
708
|
+
`Task #${task.id} cannot remain in_progress: dependencies not completed: ${open.map((depId) => `#${depId}`).join(", ")}.`,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const visiting = new Set<number>();
|
|
715
|
+
const visited = new Set<number>();
|
|
716
|
+
const visit = (id: number): void => {
|
|
717
|
+
if (visiting.has(id)) throw new TaskError(`Task #${id} is part of a dependency cycle.`);
|
|
718
|
+
if (visited.has(id)) return;
|
|
719
|
+
visiting.add(id);
|
|
720
|
+
for (const depId of (tasks.get(id) as Task).blockedBy) visit(depId);
|
|
721
|
+
visiting.delete(id);
|
|
722
|
+
visited.add(id);
|
|
723
|
+
};
|
|
724
|
+
for (const id of tasks.keys()) visit(id);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function assertValidMaxAttempts(value: unknown): asserts value is number {
|
|
728
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
729
|
+
throw new TaskError(`Invalid maxAttempts: ${String(value)}. Expected a positive safe integer.`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function assertValidId(id: number): void {
|
|
734
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) {
|
|
735
|
+
throw new TaskError(`Invalid task id: ${String(id)}. Expected a positive safe integer.`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function assertIdList(ids: number[]): void {
|
|
740
|
+
if (!Array.isArray(ids)) throw new TaskError("blockedBy must be an array of task ids.");
|
|
741
|
+
for (const id of ids) assertValidId(id);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export interface TurnStartResult {
|
|
745
|
+
store: TaskStore;
|
|
746
|
+
/**
|
|
747
|
+
* Always false. Kept for backward compatibility: turn start never deletes
|
|
748
|
+
* anything so completed history stays visible across turns. Reset happens
|
|
749
|
+
* atomically inside {@link TaskStore.create} on TaskCreate.
|
|
750
|
+
*/
|
|
751
|
+
cleaned: boolean;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Turn-start lifecycle: load the session store without deleting anything.
|
|
756
|
+
* An all-completed task list survives every turn and remains visible until
|
|
757
|
+
* the next successful TaskCreate atomically replaces it with the new task #1
|
|
758
|
+
* (see {@link TaskStore.create}).
|
|
759
|
+
*/
|
|
760
|
+
export async function turnStartStore(cwd: string, sessionId: string): Promise<TurnStartResult> {
|
|
761
|
+
const store = await TaskStore.load(taskFilePath(cwd, sessionId));
|
|
762
|
+
return { store, cleaned: false };
|
|
763
|
+
}
|