@bumpyclock/pi-tasque 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 +21 -0
- package/NOTICE.md +7 -0
- package/README.md +315 -0
- package/package.json +39 -0
- package/src/bridge/bridge-tool.ts +185 -0
- package/src/bridge/import-tsq.ts +502 -0
- package/src/bridge/link-store.ts +97 -0
- package/src/bridge/promote-todo.ts +331 -0
- package/src/bridge/types.ts +156 -0
- package/src/durable-tasks/cache.ts +167 -0
- package/src/durable-tasks/mutation-queue.ts +30 -0
- package/src/durable-tasks/runner.ts +234 -0
- package/src/durable-tasks/status.ts +184 -0
- package/src/durable-tasks/tools-change.ts +600 -0
- package/src/durable-tasks/tools-claim.ts +426 -0
- package/src/durable-tasks/tools-query.ts +496 -0
- package/src/durable-tasks/types.ts +193 -0
- package/src/index.ts +21 -0
- package/src/session-todos/state/invariants.ts +17 -0
- package/src/session-todos/state/replay.ts +272 -0
- package/src/session-todos/state/selectors.ts +140 -0
- package/src/session-todos/state/state-reducer.ts +292 -0
- package/src/session-todos/state/state.ts +69 -0
- package/src/session-todos/state/store.ts +37 -0
- package/src/session-todos/state/task-graph.ts +58 -0
- package/src/session-todos/todo-overlay.ts +223 -0
- package/src/session-todos/todo.ts +239 -0
- package/src/session-todos/tool/response-envelope.ts +143 -0
- package/src/session-todos/tool/types.ts +149 -0
- package/src/session-todos/view/format.ts +264 -0
- package/src/shared/tool-result.ts +81 -0
- package/src/shared/truncation.ts +150 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
TsqDependencyRef,
|
|
4
|
+
TsqShowData,
|
|
5
|
+
TsqTask,
|
|
6
|
+
TsqTaskTreeNode,
|
|
7
|
+
TsqTreeData,
|
|
8
|
+
TsqTreeResult,
|
|
9
|
+
} from "../durable-tasks/types.js";
|
|
10
|
+
import { runTsqJson } from "../durable-tasks/runner.js";
|
|
11
|
+
import { selectVisibleTasks } from "../session-todos/state/selectors.js";
|
|
12
|
+
import {
|
|
13
|
+
cloneTaskState,
|
|
14
|
+
type TaskState,
|
|
15
|
+
} from "../session-todos/state/state.js";
|
|
16
|
+
import {
|
|
17
|
+
applyTaskMutation,
|
|
18
|
+
type ApplyResult,
|
|
19
|
+
type Op,
|
|
20
|
+
} from "../session-todos/state/state-reducer.js";
|
|
21
|
+
import { commitState, getState } from "../session-todos/state/store.js";
|
|
22
|
+
import type { Task, TaskStatus } from "../session-todos/tool/types.js";
|
|
23
|
+
import {
|
|
24
|
+
errorToolDetails,
|
|
25
|
+
okToolDetails,
|
|
26
|
+
textToolResult,
|
|
27
|
+
} from "../shared/tool-result.js";
|
|
28
|
+
import type {
|
|
29
|
+
ImportTsqBridgeParams,
|
|
30
|
+
TaskBridgeActionHandler,
|
|
31
|
+
TaskBridgeDetails,
|
|
32
|
+
TaskBridgeLink,
|
|
33
|
+
TaskBridgeTodoSnapshot,
|
|
34
|
+
} from "./types.js";
|
|
35
|
+
|
|
36
|
+
export interface ImportTsqImportedTask {
|
|
37
|
+
readonly tsqId: string;
|
|
38
|
+
readonly title: string;
|
|
39
|
+
readonly todoId: number;
|
|
40
|
+
readonly created: boolean;
|
|
41
|
+
readonly blockedBy: readonly number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ImportTsqSuccessData {
|
|
45
|
+
readonly [key: string]: unknown;
|
|
46
|
+
readonly action: "import_tsq";
|
|
47
|
+
readonly tsqId: string;
|
|
48
|
+
readonly source: "tree" | "show";
|
|
49
|
+
readonly imported: readonly ImportTsqImportedTask[];
|
|
50
|
+
readonly links: readonly TaskBridgeLink[];
|
|
51
|
+
readonly created: readonly TaskBridgeLink[];
|
|
52
|
+
readonly existing: readonly TaskBridgeLink[];
|
|
53
|
+
readonly todoSnapshot: TaskBridgeTodoSnapshot;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SelectedTreeTask {
|
|
57
|
+
readonly task: TsqTask;
|
|
58
|
+
readonly node?: TsqTaskTreeNode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface LocatedImport {
|
|
62
|
+
readonly source: "tree" | "show";
|
|
63
|
+
readonly selected: readonly SelectedTreeTask[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface PreparedImport {
|
|
67
|
+
readonly state: TaskState;
|
|
68
|
+
readonly createdTodoIds: readonly number[];
|
|
69
|
+
readonly imported: readonly ImportTsqImportedTask[];
|
|
70
|
+
readonly links: readonly TaskBridgeLink[];
|
|
71
|
+
readonly created: readonly TaskBridgeLink[];
|
|
72
|
+
readonly existing: readonly TaskBridgeLink[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const FIND_TREE_ARGV = ["find", "open", "--tree"] as const;
|
|
76
|
+
const SHOW_ARGV_PREFIX = ["show"] as const;
|
|
77
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
78
|
+
|
|
79
|
+
export const importTsqHandler: TaskBridgeActionHandler<
|
|
80
|
+
ImportTsqBridgeParams
|
|
81
|
+
> = async (params, ctx) => {
|
|
82
|
+
const tsqId = normalizeOptionalString(params.tsqId);
|
|
83
|
+
if (tsqId === undefined) {
|
|
84
|
+
return validationErrorResult("tsqId is required");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const owner =
|
|
88
|
+
normalizeOptionalString(params.owner) ??
|
|
89
|
+
normalizeOptionalString(params.assignee);
|
|
90
|
+
const options = buildRunOptions(ctx.signal);
|
|
91
|
+
|
|
92
|
+
let located: LocatedImport;
|
|
93
|
+
try {
|
|
94
|
+
located = await locateImportTasks(tsqId, ctx, options);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return importFailureResult(tsqId, error);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let prepared: PreparedImport;
|
|
100
|
+
try {
|
|
101
|
+
prepared = prepareImport(getState(), located.selected, owner);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return importFailureResult(tsqId, error);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
commitState(prepared.state);
|
|
107
|
+
const todoSnapshot = cloneTaskState(prepared.state);
|
|
108
|
+
|
|
109
|
+
const data: ImportTsqSuccessData = {
|
|
110
|
+
action: "import_tsq",
|
|
111
|
+
tsqId,
|
|
112
|
+
source: located.source,
|
|
113
|
+
imported: prepared.imported,
|
|
114
|
+
links: prepared.links,
|
|
115
|
+
created: prepared.created,
|
|
116
|
+
existing: prepared.existing,
|
|
117
|
+
todoSnapshot,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return textToolResult(formatImportResult(data), okToolDetails(data));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
async function locateImportTasks(
|
|
124
|
+
tsqId: string,
|
|
125
|
+
ctx: Parameters<TaskBridgeActionHandler<ImportTsqBridgeParams>>[1],
|
|
126
|
+
options: { readonly timeout: number; readonly signal?: AbortSignal },
|
|
127
|
+
): Promise<LocatedImport> {
|
|
128
|
+
const treeData = await runTsqJson<TsqTreeData | TsqTreeResult>(
|
|
129
|
+
ctx.pi,
|
|
130
|
+
{ cwd: ctx.cwd },
|
|
131
|
+
FIND_TREE_ARGV,
|
|
132
|
+
options,
|
|
133
|
+
);
|
|
134
|
+
const found = findTaskTreeNode(getTreeRoots(treeData), tsqId);
|
|
135
|
+
if (found !== undefined) {
|
|
136
|
+
return {
|
|
137
|
+
source: "tree",
|
|
138
|
+
selected: dedupeSelected([
|
|
139
|
+
{ task: found.task, node: found },
|
|
140
|
+
...found.children.map((child) => ({ task: child.task, node: child })),
|
|
141
|
+
]),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const showData = await runTsqJson<TsqShowData>(
|
|
146
|
+
ctx.pi,
|
|
147
|
+
{ cwd: ctx.cwd },
|
|
148
|
+
[...SHOW_ARGV_PREFIX, tsqId],
|
|
149
|
+
options,
|
|
150
|
+
);
|
|
151
|
+
const task = requireTask(showData.task, tsqId);
|
|
152
|
+
return {
|
|
153
|
+
source: "show",
|
|
154
|
+
selected: [{ task }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function prepareImport(
|
|
159
|
+
initialState: TaskState,
|
|
160
|
+
selected: readonly SelectedTreeTask[],
|
|
161
|
+
owner: string | undefined,
|
|
162
|
+
): PreparedImport {
|
|
163
|
+
if (selected.length === 0) {
|
|
164
|
+
throw new Error("no Tasque tasks selected for import");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let state = initialState;
|
|
168
|
+
const todoIdByTsqId = new Map<string, number>();
|
|
169
|
+
const createdTodoIds: number[] = [];
|
|
170
|
+
const existingTodoIds = new Set<number>();
|
|
171
|
+
|
|
172
|
+
for (const task of selectVisibleTasks(initialState)) {
|
|
173
|
+
const tsqId = getTaskTsqId(task);
|
|
174
|
+
if (tsqId !== undefined && !todoIdByTsqId.has(tsqId)) {
|
|
175
|
+
todoIdByTsqId.set(tsqId, task.id);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const item of selected) {
|
|
180
|
+
if (todoIdByTsqId.has(item.task.id)) {
|
|
181
|
+
const existingId = todoIdByTsqId.get(item.task.id);
|
|
182
|
+
if (existingId !== undefined) existingTodoIds.add(existingId);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const created = applyOrThrow(
|
|
187
|
+
applyTaskMutation(state, "create", {
|
|
188
|
+
subject: formatTodoSubject(item.task),
|
|
189
|
+
metadata: { tsqId: item.task.id },
|
|
190
|
+
...(owner === undefined ? {} : { owner }),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
state = created.state;
|
|
194
|
+
const todoId = getCreatedTodoId(created.op);
|
|
195
|
+
createdTodoIds.push(todoId);
|
|
196
|
+
todoIdByTsqId.set(item.task.id, todoId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const blockedByByTsqId = deriveBlockedByTsqIds(selected);
|
|
200
|
+
for (const item of selected) {
|
|
201
|
+
const blockerTsqIds = blockedByByTsqId.get(item.task.id) ?? [];
|
|
202
|
+
if (blockerTsqIds.length === 0) continue;
|
|
203
|
+
const todoId = todoIdByTsqId.get(item.task.id);
|
|
204
|
+
if (todoId === undefined) continue;
|
|
205
|
+
const blockedBy = blockerTsqIds.flatMap((blockerTsqId) => {
|
|
206
|
+
const blockerTodoId = todoIdByTsqId.get(blockerTsqId);
|
|
207
|
+
return blockerTodoId === undefined ? [] : [blockerTodoId];
|
|
208
|
+
});
|
|
209
|
+
if (blockedBy.length === 0) continue;
|
|
210
|
+
state = applyOrThrow(
|
|
211
|
+
applyTaskMutation(state, "update", {
|
|
212
|
+
id: todoId,
|
|
213
|
+
addBlockedBy: blockedBy,
|
|
214
|
+
}),
|
|
215
|
+
).state;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const links = selected.map((item) => {
|
|
219
|
+
const todoId = todoIdByTsqId.get(item.task.id);
|
|
220
|
+
if (todoId === undefined) {
|
|
221
|
+
throw new Error(`missing todo for imported Tasque task ${item.task.id}`);
|
|
222
|
+
}
|
|
223
|
+
return taskToLink(requireTodo(state, todoId), item.task.id);
|
|
224
|
+
});
|
|
225
|
+
const createdIdSet = new Set(createdTodoIds);
|
|
226
|
+
const existingIdSet = new Set(existingTodoIds);
|
|
227
|
+
const imported = selected.map((item) => {
|
|
228
|
+
const todoId = todoIdByTsqId.get(item.task.id);
|
|
229
|
+
if (todoId === undefined) {
|
|
230
|
+
throw new Error(`missing todo for imported Tasque task ${item.task.id}`);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
tsqId: item.task.id,
|
|
234
|
+
title: item.task.title,
|
|
235
|
+
todoId,
|
|
236
|
+
created: createdIdSet.has(todoId),
|
|
237
|
+
blockedBy: requireTodo(state, todoId).blockedBy ?? [],
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
state,
|
|
243
|
+
createdTodoIds,
|
|
244
|
+
imported,
|
|
245
|
+
links,
|
|
246
|
+
created: links.filter((link) => createdIdSet.has(link.todoId)),
|
|
247
|
+
existing: links.filter((link) => existingIdSet.has(link.todoId)),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function deriveBlockedByTsqIds(
|
|
252
|
+
selected: readonly SelectedTreeTask[],
|
|
253
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
254
|
+
const selectedIds = new Set(selected.map((item) => item.task.id));
|
|
255
|
+
const blockedBy = new Map<string, string[]>();
|
|
256
|
+
|
|
257
|
+
function add(blockedTsqId: string, blockerTsqId: string): void {
|
|
258
|
+
if (blockedTsqId === blockerTsqId) return;
|
|
259
|
+
if (!selectedIds.has(blockedTsqId) || !selectedIds.has(blockerTsqId))
|
|
260
|
+
return;
|
|
261
|
+
const current = blockedBy.get(blockedTsqId) ?? [];
|
|
262
|
+
if (!current.includes(blockerTsqId)) current.push(blockerTsqId);
|
|
263
|
+
blockedBy.set(blockedTsqId, current);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const item of selected) {
|
|
267
|
+
const node = item.node;
|
|
268
|
+
if (node === undefined) continue;
|
|
269
|
+
for (const edge of node.blocker_edges.filter(isBlocksEdge)) {
|
|
270
|
+
add(item.task.id, edge.id);
|
|
271
|
+
}
|
|
272
|
+
for (const edge of node.dependent_edges.filter(isBlocksEdge)) {
|
|
273
|
+
add(edge.id, item.task.id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return blockedBy;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getTreeRoots(
|
|
281
|
+
data: TsqTreeData | TsqTreeResult,
|
|
282
|
+
): readonly TsqTaskTreeNode[] {
|
|
283
|
+
if (Array.isArray(data)) {
|
|
284
|
+
return data.filter(isTaskTreeNode);
|
|
285
|
+
}
|
|
286
|
+
const tree = (data as Partial<TsqTreeResult>).tree;
|
|
287
|
+
return Array.isArray(tree) ? tree.filter(isTaskTreeNode) : [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function findTaskTreeNode(
|
|
291
|
+
roots: readonly TsqTaskTreeNode[],
|
|
292
|
+
tsqId: string,
|
|
293
|
+
): TsqTaskTreeNode | undefined {
|
|
294
|
+
for (const root of roots) {
|
|
295
|
+
if (root.task.id === tsqId) return root;
|
|
296
|
+
const child = findTaskTreeNode(root.children, tsqId);
|
|
297
|
+
if (child !== undefined) return child;
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function dedupeSelected(
|
|
303
|
+
selected: readonly SelectedTreeTask[],
|
|
304
|
+
): readonly SelectedTreeTask[] {
|
|
305
|
+
const seen = new Set<string>();
|
|
306
|
+
const deduped: SelectedTreeTask[] = [];
|
|
307
|
+
for (const item of selected) {
|
|
308
|
+
if (seen.has(item.task.id)) continue;
|
|
309
|
+
seen.add(item.task.id);
|
|
310
|
+
deduped.push(item);
|
|
311
|
+
}
|
|
312
|
+
return deduped;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function applyOrThrow(result: ApplyResult): ApplyResult {
|
|
316
|
+
if (result.op.kind === "error") {
|
|
317
|
+
throw new Error(result.op.message);
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function getCreatedTodoId(op: Op): number {
|
|
323
|
+
if (op.kind === "create") {
|
|
324
|
+
return op.taskId;
|
|
325
|
+
}
|
|
326
|
+
const message =
|
|
327
|
+
op.kind === "error" ? op.message : `unexpected todo operation ${op.kind}`;
|
|
328
|
+
throw new Error(`could not create imported todo: ${message}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function formatTodoSubject(task: TsqTask): string {
|
|
332
|
+
return `Work on ${task.id}: ${task.title}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getTaskTsqId(task: Task): string | undefined {
|
|
336
|
+
const value = task.metadata?.tsqId;
|
|
337
|
+
if (typeof value !== "string") return undefined;
|
|
338
|
+
const trimmed = value.trim();
|
|
339
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function requireTodo(state: TaskState, todoId: number): Task {
|
|
343
|
+
const todo = state.tasks.find((candidate) => candidate.id === todoId);
|
|
344
|
+
if (todo === undefined || todo.status === "deleted") {
|
|
345
|
+
throw new Error(`todo #${todoId} not found`);
|
|
346
|
+
}
|
|
347
|
+
return todo;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function taskToLink(task: Task, tsqId: string): TaskBridgeLink {
|
|
351
|
+
return {
|
|
352
|
+
todoId: task.id,
|
|
353
|
+
todoSubject: task.subject,
|
|
354
|
+
todoStatus: task.status as Exclude<TaskStatus, "deleted">,
|
|
355
|
+
tsqId,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function requireTask(value: unknown, requestedId: string): TsqTask {
|
|
360
|
+
if (!isTsqTask(value)) {
|
|
361
|
+
throw new Error(`tsq show ${requestedId} did not return task data`);
|
|
362
|
+
}
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isTaskTreeNode(value: unknown): value is TsqTaskTreeNode {
|
|
367
|
+
return (
|
|
368
|
+
isRecord(value) &&
|
|
369
|
+
isTsqTask(value.task) &&
|
|
370
|
+
Array.isArray(value.children) &&
|
|
371
|
+
Array.isArray(value.blocker_edges) &&
|
|
372
|
+
Array.isArray(value.dependent_edges)
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isTsqTask(value: unknown): value is TsqTask {
|
|
377
|
+
return (
|
|
378
|
+
isRecord(value) &&
|
|
379
|
+
typeof value.id === "string" &&
|
|
380
|
+
typeof value.title === "string" &&
|
|
381
|
+
typeof value.status === "string" &&
|
|
382
|
+
typeof value.planning_state === "string" &&
|
|
383
|
+
typeof value.priority === "number"
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isBlocksEdge(edge: TsqDependencyRef): boolean {
|
|
388
|
+
return edge.dep_type === "blocks";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function normalizeOptionalString(value: unknown): string | undefined {
|
|
392
|
+
if (typeof value !== "string") return undefined;
|
|
393
|
+
const trimmed = value.trim();
|
|
394
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildRunOptions(signal: AbortSignal | undefined): {
|
|
398
|
+
readonly timeout: number;
|
|
399
|
+
readonly signal?: AbortSignal;
|
|
400
|
+
} {
|
|
401
|
+
return {
|
|
402
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
403
|
+
...(signal === undefined ? {} : { signal }),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function validationErrorResult(
|
|
408
|
+
message: string,
|
|
409
|
+
): AgentToolResult<TaskBridgeDetails> {
|
|
410
|
+
return textToolResult(
|
|
411
|
+
`Error: ${message}`,
|
|
412
|
+
errorToolDetails({ code: "validation_error", message }),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function importFailureResult(
|
|
417
|
+
tsqId: string,
|
|
418
|
+
error: unknown,
|
|
419
|
+
): AgentToolResult<TaskBridgeDetails> {
|
|
420
|
+
const message = getErrorMessage(error);
|
|
421
|
+
return textToolResult(
|
|
422
|
+
`Error: ${message}`,
|
|
423
|
+
errorToolDetails({
|
|
424
|
+
code: getErrorCode(error),
|
|
425
|
+
message,
|
|
426
|
+
details: {
|
|
427
|
+
tsqId,
|
|
428
|
+
error: serializeError(error),
|
|
429
|
+
},
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function formatImportResult(data: ImportTsqSuccessData): string {
|
|
435
|
+
const lines = [
|
|
436
|
+
`Imported ${data.links.length} Tasque ${data.links.length === 1 ? "task" : "tasks"} from ${data.source}`,
|
|
437
|
+
];
|
|
438
|
+
for (const link of data.created) {
|
|
439
|
+
lines.push(`Created todo #${link.todoId}: ${link.todoSubject}`);
|
|
440
|
+
}
|
|
441
|
+
for (const link of data.existing) {
|
|
442
|
+
lines.push(`Existing todo #${link.todoId}: ${link.todoSubject}`);
|
|
443
|
+
}
|
|
444
|
+
return lines.join("\n");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function getErrorMessage(error: unknown): string {
|
|
448
|
+
if (error instanceof Error) {
|
|
449
|
+
return error.message;
|
|
450
|
+
}
|
|
451
|
+
return String(error);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function getErrorCode(error: unknown): string {
|
|
455
|
+
const record = asRecord(error);
|
|
456
|
+
if (typeof record?.code === "string") {
|
|
457
|
+
return record.code;
|
|
458
|
+
}
|
|
459
|
+
return "import_error";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function serializeError(error: unknown): Record<string, unknown> {
|
|
463
|
+
if (error instanceof Error) {
|
|
464
|
+
return {
|
|
465
|
+
name: error.name,
|
|
466
|
+
message: error.message,
|
|
467
|
+
...(error.stack === undefined ? {} : { stack: error.stack }),
|
|
468
|
+
...copyKnownErrorFields(error),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
return { value: String(error) };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function copyKnownErrorFields(error: Error): Record<string, unknown> {
|
|
475
|
+
const record = error as unknown as Record<string, unknown>;
|
|
476
|
+
const output: Record<string, unknown> = {};
|
|
477
|
+
for (const key of [
|
|
478
|
+
"code",
|
|
479
|
+
"command",
|
|
480
|
+
"details",
|
|
481
|
+
"stderr",
|
|
482
|
+
"stdout",
|
|
483
|
+
"killed",
|
|
484
|
+
"args",
|
|
485
|
+
] as const) {
|
|
486
|
+
if (record[key] !== undefined) {
|
|
487
|
+
output[key] = record[key];
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return output;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
494
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
return value as Record<string, unknown>;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
501
|
+
return asRecord(value) !== undefined;
|
|
502
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { selectVisibleTasks } from "../session-todos/state/selectors.js";
|
|
2
|
+
import { applyTaskMutation } from "../session-todos/state/state-reducer.js";
|
|
3
|
+
import type { TaskState } from "../session-todos/state/state.js";
|
|
4
|
+
import { commitState, getState } from "../session-todos/state/store.js";
|
|
5
|
+
import type { Task } from "../session-todos/tool/types.js";
|
|
6
|
+
import type { TaskBridgeLink } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export type LinkTodoResult =
|
|
9
|
+
| {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly link: TaskBridgeLink;
|
|
12
|
+
readonly todo: Task;
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
readonly ok: false;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function linkTodoToTsq(todoId: number, tsqId: string): LinkTodoResult {
|
|
20
|
+
if (!Number.isInteger(todoId) || todoId < 1) {
|
|
21
|
+
return { ok: false, message: "todoId is required" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const normalizedTsqId = tsqId.trim();
|
|
25
|
+
if (normalizedTsqId.length === 0) {
|
|
26
|
+
return { ok: false, message: "tsqId is required" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const state = getState();
|
|
30
|
+
const existing = state.tasks.find((task) => task.id === todoId);
|
|
31
|
+
if (existing === undefined) {
|
|
32
|
+
return { ok: false, message: `todo #${todoId} not found` };
|
|
33
|
+
}
|
|
34
|
+
if (existing.status === "deleted") {
|
|
35
|
+
return { ok: false, message: `todo #${todoId} is deleted` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const updated = applyTaskMutation(state, "update", {
|
|
39
|
+
id: todoId,
|
|
40
|
+
metadata: { tsqId: normalizedTsqId },
|
|
41
|
+
});
|
|
42
|
+
if (updated.op.kind === "error") {
|
|
43
|
+
return { ok: false, message: updated.op.message };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
commitState(updated.state);
|
|
47
|
+
const todo = updated.state.tasks.find((task) => task.id === todoId);
|
|
48
|
+
if (todo === undefined || todo.status === "deleted") {
|
|
49
|
+
return { ok: false, message: `todo #${todoId} not found` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
link: taskToLink(todo, normalizedTsqId),
|
|
55
|
+
todo,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function deriveTaskLinks(
|
|
60
|
+
state: TaskState = getState(),
|
|
61
|
+
): TaskBridgeLink[] {
|
|
62
|
+
return selectVisibleTasks(state).flatMap((task) => {
|
|
63
|
+
const tsqId = getTaskTsqId(task);
|
|
64
|
+
return tsqId === undefined ? [] : [taskToLink(task, tsqId)];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getTaskLink(
|
|
69
|
+
todoId: number,
|
|
70
|
+
state: TaskState = getState(),
|
|
71
|
+
): TaskBridgeLink | undefined {
|
|
72
|
+
const task = selectVisibleTasks(state).find(
|
|
73
|
+
(candidate) => candidate.id === todoId,
|
|
74
|
+
);
|
|
75
|
+
if (task === undefined) return undefined;
|
|
76
|
+
const tsqId = getTaskTsqId(task);
|
|
77
|
+
return tsqId === undefined ? undefined : taskToLink(task, tsqId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getTaskTsqId(task: Task): string | undefined {
|
|
81
|
+
const value = task.metadata?.tsqId;
|
|
82
|
+
if (typeof value !== "string") return undefined;
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function taskToLink(task: Task, tsqId: string): TaskBridgeLink {
|
|
88
|
+
if (task.status === "deleted") {
|
|
89
|
+
throw new Error("deleted todos cannot be represented as bridge links");
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
todoId: task.id,
|
|
93
|
+
todoSubject: task.subject,
|
|
94
|
+
todoStatus: task.status,
|
|
95
|
+
tsqId,
|
|
96
|
+
};
|
|
97
|
+
}
|