@astralyn/pi 0.84.3 → 0.84.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/CHANGELOG.md +24 -0
- package/dist/cli/project-trust.d.ts.map +1 -1
- package/dist/cli/project-trust.js +1 -4
- package/dist/cli/project-trust.js.map +1 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/types.d.ts +2 -13
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/extensions/subagent/agents-command.d.ts +4 -0
- package/dist/extensions/subagent/agents-command.d.ts.map +1 -0
- package/dist/extensions/subagent/agents-command.js +230 -0
- package/dist/extensions/subagent/agents-command.js.map +1 -0
- package/dist/extensions/subagent/agents.d.ts.map +1 -1
- package/dist/extensions/subagent/agents.js +2 -0
- package/dist/extensions/subagent/agents.js.map +1 -1
- package/dist/extensions/subagent/display-name.d.ts +6 -0
- package/dist/extensions/subagent/display-name.d.ts.map +1 -0
- package/dist/extensions/subagent/display-name.js +13 -0
- package/dist/extensions/subagent/display-name.js.map +1 -0
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +2 -127
- package/dist/extensions/subagent/index.js.map +1 -1
- package/dist/extensions/subagent/render.d.ts.map +1 -1
- package/dist/extensions/subagent/render.js +1 -10
- package/dist/extensions/subagent/render.js.map +1 -1
- package/dist/extensions/subagent/settings.d.ts +0 -1
- package/dist/extensions/subagent/settings.d.ts.map +1 -1
- package/dist/extensions/subagent/settings.js +3 -7
- package/dist/extensions/subagent/settings.js.map +1 -1
- package/dist/extensions/subagent/types.d.ts +2 -0
- package/dist/extensions/subagent/types.d.ts.map +1 -1
- package/dist/extensions/subagent/types.js.map +1 -1
- package/dist/extensions/subagent/ui/model-list.d.ts +67 -0
- package/dist/extensions/subagent/ui/model-list.d.ts.map +1 -0
- package/dist/extensions/subagent/ui/model-list.js +242 -0
- package/dist/extensions/subagent/ui/model-list.js.map +1 -0
- package/dist/extensions/subagent/ui/profile-editor.d.ts +54 -0
- package/dist/extensions/subagent/ui/profile-editor.d.ts.map +1 -0
- package/dist/extensions/subagent/ui/profile-editor.js +154 -0
- package/dist/extensions/subagent/ui/profile-editor.js.map +1 -0
- package/dist/extensions/subagent/ui/profile-picker.d.ts +32 -0
- package/dist/extensions/subagent/ui/profile-picker.d.ts.map +1 -0
- package/dist/extensions/subagent/ui/profile-picker.js +149 -0
- package/dist/extensions/subagent/ui/profile-picker.js.map +1 -0
- package/dist/extensions/todo/constants.d.ts +6 -17
- package/dist/extensions/todo/constants.d.ts.map +1 -1
- package/dist/extensions/todo/constants.js +20 -31
- package/dist/extensions/todo/constants.js.map +1 -1
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +36 -57
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +20 -59
- package/dist/extensions/todo/schema.d.ts.map +1 -1
- package/dist/extensions/todo/schema.js +23 -69
- package/dist/extensions/todo/schema.js.map +1 -1
- package/dist/extensions/todo/state.d.ts +23 -65
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +238 -1262
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts +19 -10
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +411 -463
- package/dist/extensions/todo/view.js.map +1 -1
- package/dist/extensions/todo/widget.d.ts +15 -0
- package/dist/extensions/todo/widget.d.ts.map +1 -0
- package/dist/extensions/todo/widget.js +50 -0
- package/dist/extensions/todo/widget.js.map +1 -0
- package/dist/modes/interactive/components/extension-selector.d.ts +1 -2
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js +5 -11
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +1 -8
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/docs/bundled/README.md +1 -1
- package/docs/bundled/extensions/subagent.md +19 -4
- package/docs/bundled/extensions/todo.md +84 -24
- package/docs/bundled/tool-presentation.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/extensions/subagent/picker.d.ts +0 -31
- package/dist/extensions/subagent/picker.d.ts.map +0 -1
- package/dist/extensions/subagent/picker.js +0 -109
- package/dist/extensions/subagent/picker.js.map +0 -1
- package/dist/extensions/todo/overlay.d.ts +0 -24
- package/dist/extensions/todo/overlay.d.ts.map +0 -1
- package/dist/extensions/todo/overlay.js +0 -135
- package/dist/extensions/todo/overlay.js.map +0 -1
|
@@ -1,62 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* todo/state.ts — v2 pure state core: closure-scoped store, atomic actions,
|
|
3
|
+
* snapshot cloning, and branch replay. No session registry, metadata,
|
|
4
|
+
* dependency graph, tombstones, filters, or legacy normalizers.
|
|
5
|
+
*/
|
|
6
|
+
import { TODO_MAX_BATCH_ITEMS, TODO_MAX_DESCRIPTION_LENGTH, TODO_MAX_ITEMS, TODO_MAX_SUBJECT_LENGTH, TODO_TOOL_NAME, } from "./constants.js";
|
|
2
7
|
import { EMPTY_TODO_STATE, TODO_DETAILS_SCHEMA_VERSION, } from "./schema.js";
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
"get",
|
|
12
|
-
"delete",
|
|
13
|
-
"clear",
|
|
14
|
-
]);
|
|
15
|
-
const LEGACY_METADATA_LIMITS = {
|
|
16
|
-
maxDepth: 64,
|
|
17
|
-
maxEntries: 100_000,
|
|
18
|
-
maxBytes: 16 * 1024 * 1024,
|
|
8
|
+
const TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
|
|
9
|
+
const TODO_ACTIONS = new Set(["create", "update", "list", "delete"]);
|
|
10
|
+
/** Parameters each action accepts; anything else is rejected with guidance. */
|
|
11
|
+
const ACTION_PARAMS = {
|
|
12
|
+
create: new Set(["items"]),
|
|
13
|
+
update: new Set(["id", "subject", "description", "status"]),
|
|
14
|
+
list: new Set([]),
|
|
15
|
+
delete: new Set(["ids"]),
|
|
19
16
|
};
|
|
20
|
-
// ---- per-session state ------------------------------------------------------
|
|
21
|
-
// Keyed by session id: one Pi process can host more than one session over its
|
|
22
|
-
// lifetime (resume, /tree branch switches), and a module-level singleton would
|
|
23
|
-
// leak one session's list into another. Tool renderers get no ctx, so the
|
|
24
|
-
// active session is a module-level pointer kept current by execute and the
|
|
25
|
-
// lifecycle handlers (which do have ctx).
|
|
26
|
-
const states = new Map();
|
|
27
|
-
let activeSid = "";
|
|
28
|
-
/** Point the module at a session's state bucket. Call wherever ctx is in hand. */
|
|
29
|
-
export function setActiveTodoSession(sid) {
|
|
30
|
-
activeSid = sid;
|
|
31
|
-
}
|
|
32
|
-
/** Snapshot of the active session's state; mutate via applyTodoMutation + commitTodoState. */
|
|
33
|
-
export function getTodoState() {
|
|
34
|
-
return cloneState(states.get(activeSid) ?? EMPTY_TODO_STATE);
|
|
35
|
-
}
|
|
36
|
-
export function replaceTodoState(next) {
|
|
37
|
-
states.set(activeSid, cloneState(next));
|
|
38
|
-
}
|
|
39
|
-
export function commitTodoState(next) {
|
|
40
|
-
replaceTodoState(next);
|
|
41
|
-
}
|
|
42
|
-
/** Drop in-memory state for a session (call from session_shutdown). */
|
|
43
|
-
export function disposeTodoSession(sid) {
|
|
44
|
-
if (!sid)
|
|
45
|
-
return;
|
|
46
|
-
states.delete(sid);
|
|
47
|
-
if (activeSid === sid)
|
|
48
|
-
activeSid = "";
|
|
49
|
-
}
|
|
50
17
|
function isRecord(value) {
|
|
51
18
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
52
19
|
}
|
|
53
|
-
function
|
|
54
|
-
if (!isRecord(value))
|
|
55
|
-
return false;
|
|
56
|
-
const prototype = Object.getPrototypeOf(value);
|
|
57
|
-
return prototype === Object.prototype || prototype === null;
|
|
58
|
-
}
|
|
59
|
-
function isPositiveInteger(value) {
|
|
20
|
+
function isPositiveSafeInteger(value) {
|
|
60
21
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
61
22
|
}
|
|
62
23
|
function isTodoStatus(value) {
|
|
@@ -65,243 +26,31 @@ function isTodoStatus(value) {
|
|
|
65
26
|
function isTodoAction(value) {
|
|
66
27
|
return typeof value === "string" && TODO_ACTIONS.has(value);
|
|
67
28
|
}
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
for (let index = 0; index < value.length; index++) {
|
|
71
|
-
const code = value.charCodeAt(index);
|
|
72
|
-
if (code === 0x22 ||
|
|
73
|
-
code === 0x5c ||
|
|
74
|
-
code === 0x08 ||
|
|
75
|
-
code === 0x0c ||
|
|
76
|
-
code === 0x0a ||
|
|
77
|
-
code === 0x0d ||
|
|
78
|
-
code === 0x09) {
|
|
79
|
-
bytes += 2;
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
if (code <= 0x1f) {
|
|
83
|
-
bytes += 6;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
if (code >= 0xd800 && code <= 0xdbff) {
|
|
87
|
-
const next = value.charCodeAt(index + 1);
|
|
88
|
-
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
89
|
-
bytes += 4;
|
|
90
|
-
index++;
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
bytes += 6; // JSON escapes lone surrogates.
|
|
94
|
-
}
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
98
|
-
bytes += 6;
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
|
|
102
|
-
}
|
|
103
|
-
return bytes;
|
|
104
|
-
}
|
|
105
|
-
function addMetadataBytes(stats, amount, limits) {
|
|
106
|
-
stats.bytes += amount;
|
|
107
|
-
if (stats.bytes > limits.maxBytes)
|
|
108
|
-
throw new Error(`metadata exceeds ${limits.maxBytes} UTF-8 bytes`);
|
|
109
|
-
}
|
|
110
|
-
function addMetadataEntry(stats, limits) {
|
|
111
|
-
stats.entries++;
|
|
112
|
-
if (stats.entries > limits.maxEntries)
|
|
113
|
-
throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Validate and clone JSON-like values without using JSON.stringify as the
|
|
117
|
-
* validator. This rejects cycles, accessors, non-finite numbers, functions,
|
|
118
|
-
* symbols, bigint, undefined, and non-plain object instances.
|
|
119
|
-
*/
|
|
120
|
-
function cloneJsonValue(value, depth, ancestors, stats, limits) {
|
|
121
|
-
if (depth > limits.maxDepth)
|
|
122
|
-
throw new Error(`metadata exceeds depth ${limits.maxDepth}`);
|
|
123
|
-
if (value === null) {
|
|
124
|
-
addMetadataBytes(stats, 4, limits);
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
if (typeof value === "string") {
|
|
128
|
-
addMetadataBytes(stats, jsonStringUtf8Bytes(value), limits);
|
|
129
|
-
return value;
|
|
130
|
-
}
|
|
131
|
-
if (typeof value === "boolean") {
|
|
132
|
-
addMetadataBytes(stats, value ? 4 : 5, limits);
|
|
133
|
-
return value;
|
|
134
|
-
}
|
|
135
|
-
if (typeof value === "number") {
|
|
136
|
-
if (!Number.isFinite(value))
|
|
137
|
-
throw new Error("metadata numbers must be finite");
|
|
138
|
-
addMetadataBytes(stats, Buffer.byteLength(String(value), "utf8"), limits);
|
|
139
|
-
return value;
|
|
140
|
-
}
|
|
141
|
-
if (typeof value !== "object")
|
|
142
|
-
throw new Error("metadata must contain only JSON-like values");
|
|
143
|
-
if (ancestors.has(value))
|
|
144
|
-
throw new Error("metadata cannot contain cycles");
|
|
145
|
-
ancestors.add(value);
|
|
146
|
-
try {
|
|
147
|
-
if (Array.isArray(value)) {
|
|
148
|
-
if (value.length > limits.maxEntries - stats.entries) {
|
|
149
|
-
throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
|
|
150
|
-
}
|
|
151
|
-
const ownKeys = Object.keys(value);
|
|
152
|
-
if (ownKeys.length !== value.length)
|
|
153
|
-
throw new Error("metadata arrays cannot be sparse or have extra properties");
|
|
154
|
-
const copy = [];
|
|
155
|
-
addMetadataBytes(stats, 2, limits);
|
|
156
|
-
for (let index = 0; index < value.length; index++) {
|
|
157
|
-
addMetadataEntry(stats, limits);
|
|
158
|
-
if (index > 0)
|
|
159
|
-
addMetadataBytes(stats, 1, limits);
|
|
160
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
161
|
-
if (!descriptor || !("value" in descriptor))
|
|
162
|
-
throw new Error("metadata cannot contain accessors");
|
|
163
|
-
copy.push(cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits));
|
|
164
|
-
}
|
|
165
|
-
return copy;
|
|
166
|
-
}
|
|
167
|
-
if (!isPlainRecord(value))
|
|
168
|
-
throw new Error("metadata objects must be plain objects");
|
|
169
|
-
const copy = {};
|
|
170
|
-
const keys = Object.keys(value);
|
|
171
|
-
if (keys.length > limits.maxEntries - stats.entries) {
|
|
172
|
-
throw new Error(`metadata exceeds ${limits.maxEntries} entries`);
|
|
173
|
-
}
|
|
174
|
-
addMetadataBytes(stats, 2, limits);
|
|
175
|
-
for (let index = 0; index < keys.length; index++) {
|
|
176
|
-
const key = keys[index];
|
|
177
|
-
if (limits.maxKeyLength !== undefined && key.length > limits.maxKeyLength) {
|
|
178
|
-
throw new Error(`metadata keys exceed ${limits.maxKeyLength} characters`);
|
|
179
|
-
}
|
|
180
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
181
|
-
if (!descriptor || !("value" in descriptor))
|
|
182
|
-
throw new Error("metadata cannot contain accessors");
|
|
183
|
-
addMetadataEntry(stats, limits);
|
|
184
|
-
if (index > 0)
|
|
185
|
-
addMetadataBytes(stats, 1, limits);
|
|
186
|
-
addMetadataBytes(stats, jsonStringUtf8Bytes(key) + 1, limits);
|
|
187
|
-
Object.defineProperty(copy, key, {
|
|
188
|
-
value: cloneJsonValue(descriptor.value, depth + 1, ancestors, stats, limits),
|
|
189
|
-
enumerable: true,
|
|
190
|
-
writable: true,
|
|
191
|
-
configurable: true,
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
return copy;
|
|
195
|
-
}
|
|
196
|
-
finally {
|
|
197
|
-
ancestors.delete(value);
|
|
198
|
-
}
|
|
29
|
+
function findItem(state, id) {
|
|
30
|
+
return state.items.find((item) => item.id === id);
|
|
199
31
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return { error: "metadata must be a plain object" };
|
|
207
|
-
return { metadata: cloned };
|
|
32
|
+
/** De-duplicate ids preserving first-seen input order. */
|
|
33
|
+
function dedupeIds(ids) {
|
|
34
|
+
const seen = [];
|
|
35
|
+
for (const id of ids) {
|
|
36
|
+
if (!seen.includes(id))
|
|
37
|
+
seen.push(id);
|
|
208
38
|
}
|
|
209
|
-
|
|
210
|
-
return { error: error instanceof Error ? error.message : "metadata is invalid" };
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
function cloneStoredMetadata(value) {
|
|
214
|
-
const result = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);
|
|
215
|
-
return "metadata" in result ? result.metadata : undefined;
|
|
39
|
+
return seen;
|
|
216
40
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const clone = { ...item };
|
|
221
|
-
if (item.blockedBy)
|
|
222
|
-
clone.blockedBy = [...item.blockedBy];
|
|
223
|
-
else
|
|
224
|
-
delete clone.blockedBy;
|
|
225
|
-
if (item.metadata) {
|
|
226
|
-
const metadata = cloneStoredMetadata(item.metadata);
|
|
227
|
-
if (metadata)
|
|
228
|
-
clone.metadata = metadata;
|
|
229
|
-
else
|
|
230
|
-
delete clone.metadata;
|
|
231
|
-
}
|
|
232
|
-
else
|
|
233
|
-
delete clone.metadata;
|
|
234
|
-
return clone;
|
|
235
|
-
}),
|
|
236
|
-
nextId: source.nextId,
|
|
237
|
-
};
|
|
41
|
+
/** Trim and collapse every internal whitespace run to a single space. */
|
|
42
|
+
function normalizeText(value) {
|
|
43
|
+
return value.trim().replace(/\s+/g, " ");
|
|
238
44
|
}
|
|
239
|
-
function
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
*/
|
|
249
|
-
function markDeleted(state, id) {
|
|
250
|
-
const items = state.items.map((item) => {
|
|
251
|
-
if (item.id === id)
|
|
252
|
-
return { ...item, status: "deleted" };
|
|
253
|
-
if (!item.blockedBy?.includes(id))
|
|
254
|
-
return item;
|
|
255
|
-
const blockedBy = item.blockedBy.filter((dependencyId) => dependencyId !== id);
|
|
256
|
-
return blockedBy.length ? { ...item, blockedBy } : omitBlockedBy(item);
|
|
257
|
-
});
|
|
258
|
-
const nextState = { items, nextId: state.nextId };
|
|
259
|
-
const releasedIds = state.items
|
|
260
|
-
.filter((item) => item.id !== id && item.status === "pending" && item.blockedBy?.includes(id))
|
|
261
|
-
.map((item) => item.id)
|
|
262
|
-
.filter((dependentId) => {
|
|
263
|
-
const dependent = findItem(nextState, dependentId);
|
|
264
|
-
return dependent !== undefined && unresolvedDependencyIds(nextState, dependent).length === 0;
|
|
265
|
-
});
|
|
266
|
-
return { state: nextState, releasedIds };
|
|
267
|
-
}
|
|
268
|
-
function error(state, message) {
|
|
269
|
-
return { state, operation: { kind: "error", message } };
|
|
270
|
-
}
|
|
271
|
-
// ---- per-action parameter validation ---------------------------------------
|
|
272
|
-
// Parameters each action accepts. Anything else is rejected with guidance
|
|
273
|
-
// instead of being silently ignored (a model that passes blockedBy on update
|
|
274
|
-
// should learn about addBlockedBy, not lose the edit).
|
|
275
|
-
const ACTION_PARAMS = {
|
|
276
|
-
create: new Set(["subject", "description", "activeForm", "status", "blockedBy", "owner", "metadata"]),
|
|
277
|
-
create_many: new Set(["items"]),
|
|
278
|
-
update: new Set([
|
|
279
|
-
"id",
|
|
280
|
-
"subject",
|
|
281
|
-
"description",
|
|
282
|
-
"activeForm",
|
|
283
|
-
"status",
|
|
284
|
-
"addBlockedBy",
|
|
285
|
-
"removeBlockedBy",
|
|
286
|
-
"addBlocks",
|
|
287
|
-
"removeBlocks",
|
|
288
|
-
"owner",
|
|
289
|
-
"metadata",
|
|
290
|
-
]),
|
|
291
|
-
list: new Set(["status", "includeDeleted", "limit", "afterId", "query", "unblockedOnly"]),
|
|
292
|
-
get: new Set(["id"]),
|
|
293
|
-
delete: new Set(["id"]),
|
|
294
|
-
clear: new Set(["confirm", "expectedCount"]),
|
|
295
|
-
};
|
|
296
|
-
const UPDATE_ONLY_EDGE_PARAMS = new Set(["addBlockedBy", "removeBlockedBy", "addBlocks", "removeBlocks"]);
|
|
297
|
-
function inapplicableParamError(action, key) {
|
|
298
|
-
if (action === "update" && key === "blockedBy") {
|
|
299
|
-
return "blockedBy is create-only; use addBlockedBy/removeBlockedBy on update";
|
|
300
|
-
}
|
|
301
|
-
if (action === "create" && UPDATE_ONLY_EDGE_PARAMS.has(key)) {
|
|
302
|
-
return `${key} is update-only; use blockedBy on create`;
|
|
303
|
-
}
|
|
304
|
-
return `${key} does not apply to action ${action}`;
|
|
45
|
+
function validateNormalizedText(value, label, maximum) {
|
|
46
|
+
if (typeof value !== "string")
|
|
47
|
+
throw new Error(`${label} must be a string`);
|
|
48
|
+
const normalized = normalizeText(value);
|
|
49
|
+
if (!normalized)
|
|
50
|
+
throw new Error(`${label} cannot be empty`);
|
|
51
|
+
if (normalized.length > maximum)
|
|
52
|
+
throw new Error(`${label} exceeds ${maximum} characters`);
|
|
53
|
+
return normalized;
|
|
305
54
|
}
|
|
306
55
|
function findInapplicableParam(params) {
|
|
307
56
|
const allowed = ACTION_PARAMS[params.action];
|
|
@@ -309,1034 +58,261 @@ function findInapplicableParam(params) {
|
|
|
309
58
|
if (key === "action" || value === undefined)
|
|
310
59
|
continue;
|
|
311
60
|
if (!allowed.has(key))
|
|
312
|
-
return
|
|
313
|
-
}
|
|
314
|
-
return undefined;
|
|
315
|
-
}
|
|
316
|
-
function validateBoundedString(value, label, maximum) {
|
|
317
|
-
if (typeof value !== "string")
|
|
318
|
-
return `${label} must be a string`;
|
|
319
|
-
if (value.length > maximum)
|
|
320
|
-
return `${label} exceeds ${maximum} characters`;
|
|
321
|
-
return undefined;
|
|
322
|
-
}
|
|
323
|
-
function validateTaskTextFields(params, requireDescription) {
|
|
324
|
-
if (params.subject !== undefined) {
|
|
325
|
-
const subjectError = validateBoundedString(params.subject, "subject", TODO_MAX_SUBJECT_LENGTH);
|
|
326
|
-
if (subjectError)
|
|
327
|
-
return subjectError;
|
|
328
|
-
}
|
|
329
|
-
if (params.description !== undefined) {
|
|
330
|
-
const descriptionError = validateBoundedString(params.description, "description", TODO_MAX_DESCRIPTION_LENGTH);
|
|
331
|
-
if (descriptionError)
|
|
332
|
-
return descriptionError;
|
|
333
|
-
}
|
|
334
|
-
if (params.activeForm !== undefined) {
|
|
335
|
-
const activeFormError = validateBoundedString(params.activeForm, "activeForm", TODO_MAX_ACTIVE_FORM_LENGTH);
|
|
336
|
-
if (activeFormError)
|
|
337
|
-
return activeFormError;
|
|
338
|
-
}
|
|
339
|
-
if (params.owner !== undefined) {
|
|
340
|
-
const ownerError = validateBoundedString(params.owner, "owner", TODO_MAX_OWNER_LENGTH);
|
|
341
|
-
if (ownerError)
|
|
342
|
-
return ownerError;
|
|
343
|
-
}
|
|
344
|
-
if (params.subject !== undefined && !params.subject.trim())
|
|
345
|
-
return "subject cannot be empty";
|
|
346
|
-
if (requireDescription && !params.description?.trim()) {
|
|
347
|
-
return "description required for create: state what done means for this task";
|
|
61
|
+
return `${key} does not apply to action ${params.action}`;
|
|
348
62
|
}
|
|
349
63
|
return undefined;
|
|
350
64
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (!Array.isArray(value))
|
|
355
|
-
return `${label} must be an array`;
|
|
356
|
-
if (value.length > TODO_MAX_BLOCKED_BY)
|
|
357
|
-
return `${label} exceeds ${TODO_MAX_BLOCKED_BY} ids`;
|
|
358
|
-
const ids = [];
|
|
359
|
-
for (const id of value) {
|
|
360
|
-
if (!isPositiveInteger(id))
|
|
361
|
-
return `${label} must contain positive integer ids`;
|
|
362
|
-
if (!ids.includes(id))
|
|
363
|
-
ids.push(id);
|
|
364
|
-
}
|
|
365
|
-
return ids;
|
|
65
|
+
/** Snapshot clone: items are plain string/number records, so a shallow copy is safe. */
|
|
66
|
+
export function cloneTodoState(state) {
|
|
67
|
+
return { items: state.items.map((item) => ({ ...item })), nextId: state.nextId };
|
|
366
68
|
}
|
|
367
|
-
function
|
|
368
|
-
// `deleted` is the only terminal status; `completed` can be reopened to
|
|
369
|
-
// in_progress or pending so a premature completion recovers without losing
|
|
370
|
-
// the task id and its blockedBy edges.
|
|
371
|
-
if (from === to)
|
|
372
|
-
return true;
|
|
373
|
-
if (from === "deleted")
|
|
374
|
-
return false;
|
|
375
|
-
if (to === "deleted")
|
|
376
|
-
return true;
|
|
377
|
-
if (to === "completed")
|
|
378
|
-
return from === "pending" || from === "in_progress";
|
|
379
|
-
if (to === "pending")
|
|
380
|
-
return from === "in_progress" || from === "completed";
|
|
381
|
-
if (to === "in_progress")
|
|
382
|
-
return from === "pending" || from === "completed";
|
|
383
|
-
return false;
|
|
384
|
-
}
|
|
385
|
-
function findItem(state, id) {
|
|
386
|
-
return state.items.find((item) => item.id === id);
|
|
387
|
-
}
|
|
388
|
-
function validateDependencies(state, deps, currentId) {
|
|
389
|
-
for (const dep of deps) {
|
|
390
|
-
if (dep === currentId)
|
|
391
|
-
return `cannot block #${currentId} on itself`;
|
|
392
|
-
const item = findItem(state, dep);
|
|
393
|
-
if (!item)
|
|
394
|
-
return `blockedBy: #${dep} not found`;
|
|
395
|
-
if (item.status === "deleted")
|
|
396
|
-
return `blockedBy: #${dep} is deleted`;
|
|
397
|
-
}
|
|
398
|
-
return undefined;
|
|
399
|
-
}
|
|
400
|
-
/** All listed dependencies must be completed before starting or closing a task. */
|
|
401
|
-
function validateDependenciesReady(state, deps) {
|
|
402
|
-
for (const dep of deps) {
|
|
403
|
-
const item = findItem(state, dep);
|
|
404
|
-
if (!item || item.status !== "completed") {
|
|
405
|
-
return `blockedBy: complete #${dep} before starting or finishing this task`;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
return undefined;
|
|
409
|
-
}
|
|
410
|
-
/** IDs of dependencies that still prevent a task from starting or completing. */
|
|
411
|
-
export function unresolvedDependencyIds(state, item) {
|
|
412
|
-
return (item.blockedBy ?? []).filter((id) => findItem(state, id)?.status !== "completed");
|
|
413
|
-
}
|
|
414
|
-
/** True when every blockedBy dependency is completed (for transition and overlay checks). */
|
|
415
|
-
export function dependenciesSatisfied(state, item) {
|
|
416
|
-
return unresolvedDependencyIds(state, item).length === 0;
|
|
417
|
-
}
|
|
418
|
-
/** True when the resulting dependency graph contains a cycle. */
|
|
419
|
-
function createsCycle(items, nextDepsById) {
|
|
420
|
-
const depsById = new Map();
|
|
421
|
-
for (const item of items)
|
|
422
|
-
depsById.set(item.id, item.blockedBy ?? []);
|
|
423
|
-
for (const [id, deps] of nextDepsById)
|
|
424
|
-
depsById.set(id, deps);
|
|
425
|
-
// An iterative color walk keeps replay and dependency edits safe for long,
|
|
426
|
-
// valid chains that would overflow the JavaScript call stack recursively.
|
|
427
|
-
const colors = new Map();
|
|
428
|
-
const stack = [];
|
|
429
|
-
for (const initialId of depsById.keys()) {
|
|
430
|
-
if ((colors.get(initialId) ?? 0) !== 0)
|
|
431
|
-
continue;
|
|
432
|
-
colors.set(initialId, 1);
|
|
433
|
-
stack.push({ id: initialId, nextDependencyIndex: 0 });
|
|
434
|
-
while (stack.length) {
|
|
435
|
-
const frame = stack[stack.length - 1];
|
|
436
|
-
const dependencies = depsById.get(frame.id) ?? [];
|
|
437
|
-
if (frame.nextDependencyIndex >= dependencies.length) {
|
|
438
|
-
colors.set(frame.id, 2);
|
|
439
|
-
stack.pop();
|
|
440
|
-
continue;
|
|
441
|
-
}
|
|
442
|
-
const dependencyId = dependencies[frame.nextDependencyIndex];
|
|
443
|
-
frame.nextDependencyIndex++;
|
|
444
|
-
if (!depsById.has(dependencyId))
|
|
445
|
-
continue;
|
|
446
|
-
const color = colors.get(dependencyId) ?? 0;
|
|
447
|
-
if (color === 1)
|
|
448
|
-
return true;
|
|
449
|
-
if (color === 2)
|
|
450
|
-
continue;
|
|
451
|
-
colors.set(dependencyId, 1);
|
|
452
|
-
stack.push({ id: dependencyId, nextDependencyIndex: 0 });
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return false;
|
|
456
|
-
}
|
|
457
|
-
function validateAndNormalizeBatch(params, state) {
|
|
69
|
+
function applyCreate(state, params) {
|
|
458
70
|
const rawItems = params.items;
|
|
459
71
|
if (!Array.isArray(rawItems) || rawItems.length === 0)
|
|
460
|
-
|
|
72
|
+
throw new Error("items required for create");
|
|
461
73
|
if (rawItems.length > TODO_MAX_BATCH_ITEMS)
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
"
|
|
468
|
-
|
|
469
|
-
"
|
|
470
|
-
|
|
471
|
-
"metadata",
|
|
472
|
-
]);
|
|
473
|
-
const batch = [];
|
|
474
|
-
const keys = new Map();
|
|
74
|
+
throw new Error(`items exceeds ${TODO_MAX_BATCH_ITEMS} tasks`);
|
|
75
|
+
if (state.items.length + rawItems.length > TODO_MAX_ITEMS) {
|
|
76
|
+
throw new Error(`todo list is full (max ${TODO_MAX_ITEMS} tasks)`);
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isSafeInteger(state.nextId) || state.nextId < 1)
|
|
79
|
+
throw new Error("next id is invalid");
|
|
80
|
+
if (state.nextId > Number.MAX_SAFE_INTEGER - rawItems.length)
|
|
81
|
+
throw new Error("next id is exhausted");
|
|
82
|
+
const sources = [];
|
|
475
83
|
for (let index = 0; index < rawItems.length; index++) {
|
|
476
84
|
const raw = rawItems[index];
|
|
477
|
-
if (!
|
|
478
|
-
|
|
479
|
-
for (const
|
|
480
|
-
if (
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const taskParams = raw;
|
|
484
|
-
if (typeof raw.subject !== "string" || !raw.subject.trim())
|
|
485
|
-
return `items[${index}]: subject required for create_many`;
|
|
486
|
-
const taskTextError = validateTaskTextFields(taskParams, true);
|
|
487
|
-
if (taskTextError)
|
|
488
|
-
return `items[${index}]: ${taskTextError}`;
|
|
489
|
-
let key;
|
|
490
|
-
if (raw.key !== undefined) {
|
|
491
|
-
const keyError = validateBoundedString(raw.key, "key", TODO_MAX_BATCH_KEY_LENGTH);
|
|
492
|
-
if (keyError)
|
|
493
|
-
return `items[${index}]: ${keyError}`;
|
|
494
|
-
key = raw.key.trim();
|
|
495
|
-
if (!key)
|
|
496
|
-
return `items[${index}]: key cannot be empty`;
|
|
497
|
-
if (keys.has(key))
|
|
498
|
-
return `items[${index}]: duplicate key ${key}`;
|
|
499
|
-
keys.set(key, index);
|
|
500
|
-
}
|
|
501
|
-
const blockedBy = validateIdArray(raw.blockedBy, `items[${index}].blockedBy`);
|
|
502
|
-
if (typeof blockedBy === "string")
|
|
503
|
-
return blockedBy;
|
|
504
|
-
const blockedByError = validateDependencies(state, blockedBy);
|
|
505
|
-
if (blockedByError)
|
|
506
|
-
return `items[${index}]: ${blockedByError}`;
|
|
507
|
-
const rawBlockedByKeys = raw.blockedByKeys;
|
|
508
|
-
if (rawBlockedByKeys !== undefined && !Array.isArray(rawBlockedByKeys)) {
|
|
509
|
-
return `items[${index}].blockedByKeys must be an array`;
|
|
510
|
-
}
|
|
511
|
-
if (Array.isArray(rawBlockedByKeys) && rawBlockedByKeys.length > TODO_MAX_BLOCKED_BY) {
|
|
512
|
-
return `items[${index}].blockedByKeys exceeds ${TODO_MAX_BLOCKED_BY} keys`;
|
|
513
|
-
}
|
|
514
|
-
const blockedByKeys = [];
|
|
515
|
-
for (const reference of rawBlockedByKeys ?? []) {
|
|
516
|
-
const referenceError = validateBoundedString(reference, "blockedByKeys", TODO_MAX_BATCH_KEY_LENGTH);
|
|
517
|
-
if (referenceError)
|
|
518
|
-
return `items[${index}]: ${referenceError}`;
|
|
519
|
-
const normalizedReference = reference.trim();
|
|
520
|
-
if (!normalizedReference)
|
|
521
|
-
return `items[${index}]: blockedByKeys cannot contain an empty key`;
|
|
522
|
-
if (!blockedByKeys.includes(normalizedReference))
|
|
523
|
-
blockedByKeys.push(normalizedReference);
|
|
524
|
-
}
|
|
525
|
-
let metadata;
|
|
526
|
-
if (raw.metadata !== undefined) {
|
|
527
|
-
const metadataResult = validateAndCloneMetadata(raw.metadata, {
|
|
528
|
-
maxDepth: TODO_MAX_METADATA_DEPTH,
|
|
529
|
-
maxEntries: TODO_MAX_METADATA_ENTRIES,
|
|
530
|
-
maxBytes: TODO_MAX_METADATA_BYTES,
|
|
531
|
-
maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
|
|
532
|
-
});
|
|
533
|
-
if ("error" in metadataResult)
|
|
534
|
-
return `items[${index}]: ${metadataResult.error}`;
|
|
535
|
-
metadata = metadataResult.metadata;
|
|
85
|
+
if (!isRecord(raw))
|
|
86
|
+
throw new Error(`items[${index}] must be an object`);
|
|
87
|
+
for (const key of Object.keys(raw)) {
|
|
88
|
+
if (key !== "subject" && key !== "description") {
|
|
89
|
+
throw new Error(`items[${index}].${key} does not apply to action create`);
|
|
90
|
+
}
|
|
536
91
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
description: raw.description,
|
|
541
|
-
...(raw.activeForm ? { activeForm: raw.activeForm } : {}),
|
|
542
|
-
blockedBy,
|
|
543
|
-
blockedByKeys,
|
|
544
|
-
...(raw.owner ? { owner: raw.owner } : {}),
|
|
545
|
-
...(metadata ? { metadata } : {}),
|
|
92
|
+
sources.push({
|
|
93
|
+
subject: validateNormalizedText(raw.subject, `items[${index}].subject`, TODO_MAX_SUBJECT_LENGTH),
|
|
94
|
+
description: validateNormalizedText(raw.description, `items[${index}].description`, TODO_MAX_DESCRIPTION_LENGTH),
|
|
546
95
|
});
|
|
547
96
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
97
|
+
// Atomic: everything above is validated before the new state is built.
|
|
98
|
+
const created = sources.map((source, index) => ({
|
|
99
|
+
id: state.nextId + index,
|
|
100
|
+
subject: source.subject,
|
|
101
|
+
description: source.description,
|
|
102
|
+
status: "pending",
|
|
103
|
+
}));
|
|
104
|
+
return { items: [...state.items, ...created], nextId: state.nextId + created.length };
|
|
105
|
+
}
|
|
106
|
+
function applyUpdate(state, params) {
|
|
107
|
+
const id = params.id;
|
|
108
|
+
if (!isPositiveSafeInteger(id))
|
|
109
|
+
throw new Error("id required for update");
|
|
110
|
+
const index = state.items.findIndex((item) => item.id === id);
|
|
111
|
+
if (index === -1)
|
|
112
|
+
throw new Error(`#${id} not found`);
|
|
113
|
+
if (params.subject === undefined && params.description === undefined && params.status === undefined) {
|
|
114
|
+
throw new Error("update requires at least one of subject, description, or status");
|
|
115
|
+
}
|
|
116
|
+
const subject = params.subject === undefined
|
|
117
|
+
? undefined
|
|
118
|
+
: validateNormalizedText(params.subject, "subject", TODO_MAX_SUBJECT_LENGTH);
|
|
119
|
+
const description = params.description === undefined
|
|
120
|
+
? undefined
|
|
121
|
+
: validateNormalizedText(params.description, "description", TODO_MAX_DESCRIPTION_LENGTH);
|
|
122
|
+
if (params.status !== undefined && !isTodoStatus(params.status))
|
|
123
|
+
throw new Error("status is invalid");
|
|
124
|
+
const current = state.items[index];
|
|
125
|
+
const updated = {
|
|
126
|
+
id: current.id,
|
|
127
|
+
subject: subject ?? current.subject,
|
|
128
|
+
description: description ?? current.description,
|
|
129
|
+
status: params.status ?? current.status,
|
|
130
|
+
};
|
|
131
|
+
// Exactly one in_progress: setting one demotes every other active task.
|
|
132
|
+
const items = state.items.map((item, itemIndex) => {
|
|
133
|
+
if (itemIndex === index)
|
|
134
|
+
return updated;
|
|
135
|
+
if (updated.status === "in_progress" && item.status === "in_progress") {
|
|
136
|
+
return { ...item, status: "pending" };
|
|
579
137
|
}
|
|
580
|
-
const item = {
|
|
581
|
-
id: state.nextId + index,
|
|
582
|
-
subject: source.subject,
|
|
583
|
-
description: source.description,
|
|
584
|
-
status: "pending",
|
|
585
|
-
};
|
|
586
|
-
if (source.activeForm)
|
|
587
|
-
item.activeForm = source.activeForm;
|
|
588
|
-
if (blockedBy.length)
|
|
589
|
-
item.blockedBy = blockedBy;
|
|
590
|
-
if (source.owner)
|
|
591
|
-
item.owner = source.owner;
|
|
592
|
-
if (source.metadata)
|
|
593
|
-
item.metadata = source.metadata;
|
|
594
138
|
return item;
|
|
595
139
|
});
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
140
|
+
return { items, nextId: state.nextId };
|
|
141
|
+
}
|
|
142
|
+
function applyDelete(state, params) {
|
|
143
|
+
const rawIds = params.ids;
|
|
144
|
+
if (!Array.isArray(rawIds) || rawIds.length === 0)
|
|
145
|
+
throw new Error("ids required for delete");
|
|
146
|
+
if (rawIds.length > TODO_MAX_BATCH_ITEMS)
|
|
147
|
+
throw new Error(`ids exceeds ${TODO_MAX_BATCH_ITEMS} ids`);
|
|
148
|
+
const ids = [];
|
|
149
|
+
for (const raw of rawIds) {
|
|
150
|
+
if (!isPositiveSafeInteger(raw))
|
|
151
|
+
throw new Error("ids must contain positive integer ids");
|
|
152
|
+
ids.push(raw);
|
|
153
|
+
}
|
|
154
|
+
const idsToRemove = dedupeIds(ids);
|
|
155
|
+
// Atomic: every id must exist before anything is removed.
|
|
156
|
+
const byId = new Map(state.items.map((item) => [item.id, item]));
|
|
157
|
+
for (const candidate of idsToRemove) {
|
|
158
|
+
if (!byId.has(candidate))
|
|
159
|
+
throw new Error(`#${candidate} not found`);
|
|
160
|
+
}
|
|
161
|
+
const remove = new Set(idsToRemove);
|
|
162
|
+
return { items: state.items.filter((item) => !remove.has(item.id)), nextId: state.nextId };
|
|
605
163
|
}
|
|
606
|
-
|
|
607
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Pure action application: validates every runtime input and inapplicable
|
|
166
|
+
* parameter (tool args can be tampered after schema validation), throws on
|
|
167
|
+
* any invalid call, and never mutates the input state.
|
|
168
|
+
*/
|
|
169
|
+
export function applyTodoAction(state, params) {
|
|
170
|
+
if (!isRecord(params))
|
|
171
|
+
throw new Error("todo params must be an object");
|
|
608
172
|
if (!isTodoAction(params.action))
|
|
609
|
-
|
|
173
|
+
throw new Error(`unknown todo action: ${String(params.action)}`);
|
|
610
174
|
const inapplicable = findInapplicableParam(params);
|
|
611
175
|
if (inapplicable)
|
|
612
|
-
|
|
176
|
+
throw new Error(inapplicable);
|
|
177
|
+
switch (params.action) {
|
|
178
|
+
case "create":
|
|
179
|
+
return applyCreate(state, params);
|
|
180
|
+
case "update":
|
|
181
|
+
return applyUpdate(state, params);
|
|
182
|
+
case "list":
|
|
183
|
+
return cloneTodoState(state);
|
|
184
|
+
case "delete":
|
|
185
|
+
return applyDelete(state, params);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Derive the operation model for one call from the before/after states. */
|
|
189
|
+
export function buildTodoChange(params, before, after) {
|
|
613
190
|
switch (params.action) {
|
|
614
191
|
case "create": {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const taskTextError = validateTaskTextFields(params, true);
|
|
618
|
-
if (taskTextError)
|
|
619
|
-
return error(state, taskTextError);
|
|
620
|
-
if (params.status !== undefined && params.status !== "pending") {
|
|
621
|
-
return error(state, "create always starts pending; use update to start or complete a task");
|
|
622
|
-
}
|
|
623
|
-
const blockedBy = validateIdArray(params.blockedBy, "blockedBy");
|
|
624
|
-
if (typeof blockedBy === "string")
|
|
625
|
-
return error(state, blockedBy);
|
|
626
|
-
const dependencyError = validateDependencies(state, blockedBy);
|
|
627
|
-
if (dependencyError)
|
|
628
|
-
return error(state, dependencyError);
|
|
629
|
-
let metadata;
|
|
630
|
-
if (params.metadata !== undefined) {
|
|
631
|
-
const metadataResult = validateAndCloneMetadata(params.metadata, {
|
|
632
|
-
maxDepth: TODO_MAX_METADATA_DEPTH,
|
|
633
|
-
maxEntries: TODO_MAX_METADATA_ENTRIES,
|
|
634
|
-
maxBytes: TODO_MAX_METADATA_BYTES,
|
|
635
|
-
maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
|
|
636
|
-
});
|
|
637
|
-
if ("error" in metadataResult)
|
|
638
|
-
return error(state, metadataResult.error);
|
|
639
|
-
metadata = metadataResult.metadata;
|
|
640
|
-
}
|
|
641
|
-
if (!Number.isSafeInteger(state.nextId) || state.nextId < 1 || state.nextId === Number.MAX_SAFE_INTEGER) {
|
|
642
|
-
return error(state, "next id is invalid or exhausted");
|
|
643
|
-
}
|
|
644
|
-
const item = {
|
|
645
|
-
id: state.nextId,
|
|
646
|
-
subject: params.subject.trim(),
|
|
647
|
-
status: "pending",
|
|
648
|
-
description: params.description,
|
|
649
|
-
};
|
|
650
|
-
if (params.activeForm)
|
|
651
|
-
item.activeForm = params.activeForm;
|
|
652
|
-
if (blockedBy.length)
|
|
653
|
-
item.blockedBy = blockedBy;
|
|
654
|
-
if (params.owner)
|
|
655
|
-
item.owner = params.owner;
|
|
656
|
-
if (metadata)
|
|
657
|
-
item.metadata = metadata;
|
|
658
|
-
return {
|
|
659
|
-
state: { items: [...state.items, item], nextId: state.nextId + 1 },
|
|
660
|
-
operation: { kind: "create", id: item.id },
|
|
661
|
-
};
|
|
192
|
+
const count = Array.isArray(params.items) ? params.items.length : 0;
|
|
193
|
+
return { kind: "create", ids: after.items.slice(after.items.length - count).map((item) => item.id) };
|
|
662
194
|
}
|
|
663
|
-
case "create_many":
|
|
664
|
-
return createMany(state, params);
|
|
665
195
|
case "update": {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
return error(state, `#${params.id} not found`);
|
|
671
|
-
const current = state.items[index];
|
|
672
|
-
if (current.status === "deleted")
|
|
673
|
-
return error(state, `#${params.id} is deleted and cannot be updated`);
|
|
674
|
-
const taskTextError = validateTaskTextFields(params, false);
|
|
675
|
-
if (taskTextError)
|
|
676
|
-
return error(state, taskTextError);
|
|
677
|
-
if (params.status !== undefined && !isTodoStatus(params.status))
|
|
678
|
-
return error(state, "status is invalid");
|
|
679
|
-
const addBlockedBy = validateIdArray(params.addBlockedBy, "addBlockedBy");
|
|
680
|
-
const removeBlockedBy = validateIdArray(params.removeBlockedBy, "removeBlockedBy");
|
|
681
|
-
const addBlocks = validateIdArray(params.addBlocks, "addBlocks");
|
|
682
|
-
const removeBlocks = validateIdArray(params.removeBlocks, "removeBlocks");
|
|
683
|
-
if (typeof addBlockedBy === "string")
|
|
684
|
-
return error(state, addBlockedBy);
|
|
685
|
-
if (typeof removeBlockedBy === "string")
|
|
686
|
-
return error(state, removeBlockedBy);
|
|
687
|
-
if (typeof addBlocks === "string")
|
|
688
|
-
return error(state, addBlocks);
|
|
689
|
-
if (typeof removeBlocks === "string")
|
|
690
|
-
return error(state, removeBlocks);
|
|
691
|
-
const edgeChange = addBlockedBy.length > 0 || removeBlockedBy.length > 0 || addBlocks.length > 0 || removeBlocks.length > 0;
|
|
692
|
-
const fieldChange = params.subject !== undefined ||
|
|
693
|
-
params.description !== undefined ||
|
|
694
|
-
params.activeForm !== undefined ||
|
|
695
|
-
params.owner !== undefined ||
|
|
696
|
-
params.metadata !== undefined;
|
|
697
|
-
if (!fieldChange && !edgeChange && params.status === undefined) {
|
|
698
|
-
return error(state, "update requires at least one field");
|
|
699
|
-
}
|
|
700
|
-
if (params.status === "deleted") {
|
|
701
|
-
if (fieldChange || edgeChange) {
|
|
702
|
-
return error(state, "status deleted cannot be combined with other changes; apply edits first or delete in a separate call");
|
|
703
|
-
}
|
|
704
|
-
const deleted = markDeleted(state, current.id);
|
|
705
|
-
return {
|
|
706
|
-
state: deleted.state,
|
|
707
|
-
operation: {
|
|
708
|
-
kind: "update",
|
|
709
|
-
id: current.id,
|
|
710
|
-
from: current.status,
|
|
711
|
-
to: "deleted",
|
|
712
|
-
...(deleted.releasedIds.length ? { releasedIds: deleted.releasedIds } : {}),
|
|
713
|
-
},
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
const nextStatus = params.status ?? current.status;
|
|
717
|
-
if (!isTransitionAllowed(current.status, nextStatus)) {
|
|
718
|
-
return error(state, `illegal transition ${current.status} -> ${nextStatus}`);
|
|
719
|
-
}
|
|
720
|
-
let metadata;
|
|
721
|
-
if (params.metadata !== undefined) {
|
|
722
|
-
const patchResult = validateAndCloneMetadata(params.metadata, {
|
|
723
|
-
maxDepth: TODO_MAX_METADATA_DEPTH,
|
|
724
|
-
maxEntries: TODO_MAX_METADATA_ENTRIES,
|
|
725
|
-
maxBytes: TODO_MAX_METADATA_BYTES,
|
|
726
|
-
maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
|
|
727
|
-
});
|
|
728
|
-
if ("error" in patchResult)
|
|
729
|
-
return error(state, patchResult.error);
|
|
730
|
-
const merged = Object.create(null);
|
|
731
|
-
for (const [key, value] of Object.entries(current.metadata ?? {}))
|
|
732
|
-
merged[key] = value;
|
|
733
|
-
for (const [key, value] of Object.entries(patchResult.metadata)) {
|
|
734
|
-
if (value === null)
|
|
735
|
-
delete merged[key];
|
|
736
|
-
else
|
|
737
|
-
merged[key] = value;
|
|
738
|
-
}
|
|
739
|
-
if (Object.keys(merged).length) {
|
|
740
|
-
const mergedResult = validateAndCloneMetadata(merged, {
|
|
741
|
-
maxDepth: TODO_MAX_METADATA_DEPTH,
|
|
742
|
-
maxEntries: TODO_MAX_METADATA_ENTRIES,
|
|
743
|
-
maxBytes: TODO_MAX_METADATA_BYTES,
|
|
744
|
-
maxKeyLength: TODO_MAX_METADATA_KEY_LENGTH,
|
|
745
|
-
});
|
|
746
|
-
if ("error" in mergedResult)
|
|
747
|
-
return error(state, mergedResult.error);
|
|
748
|
-
metadata = mergedResult.metadata;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
// Collect every task whose blockedBy changes: this task (addBlockedBy /
|
|
752
|
-
// removeBlockedBy) plus reverse-edge targets (addBlocks / removeBlocks).
|
|
753
|
-
const nextDepsById = new Map();
|
|
754
|
-
let nextDeps = current.blockedBy ? [...current.blockedBy] : [];
|
|
755
|
-
if (removeBlockedBy.length) {
|
|
756
|
-
const remove = new Set(removeBlockedBy);
|
|
757
|
-
nextDeps = nextDeps.filter((id) => !remove.has(id));
|
|
758
|
-
}
|
|
759
|
-
if (addBlockedBy.length) {
|
|
760
|
-
const dependencyError = validateDependencies(state, addBlockedBy, current.id);
|
|
761
|
-
if (dependencyError)
|
|
762
|
-
return error(state, dependencyError);
|
|
763
|
-
for (const dep of addBlockedBy) {
|
|
764
|
-
if (!nextDeps.includes(dep))
|
|
765
|
-
nextDeps.push(dep);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
nextDepsById.set(current.id, nextDeps);
|
|
769
|
-
if (removeBlocks.length) {
|
|
770
|
-
const release = new Set(removeBlocks);
|
|
771
|
-
for (const candidate of state.items) {
|
|
772
|
-
if (!release.has(candidate.id) || !candidate.blockedBy?.includes(current.id))
|
|
773
|
-
continue;
|
|
774
|
-
nextDepsById.set(candidate.id, candidate.blockedBy.filter((dependencyId) => dependencyId !== current.id));
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
if (addBlocks.length) {
|
|
778
|
-
for (const targetId of addBlocks) {
|
|
779
|
-
if (targetId === current.id)
|
|
780
|
-
return error(state, `cannot block #${targetId} on itself`);
|
|
781
|
-
const target = findItem(state, targetId);
|
|
782
|
-
if (!target)
|
|
783
|
-
return error(state, `addBlocks: #${targetId} not found`);
|
|
784
|
-
if (target.status === "deleted")
|
|
785
|
-
return error(state, `addBlocks: #${targetId} is deleted`);
|
|
786
|
-
const deps = nextDepsById.get(targetId) ?? (target.blockedBy ? [...target.blockedBy] : []);
|
|
787
|
-
if (!deps.includes(current.id))
|
|
788
|
-
deps.push(current.id);
|
|
789
|
-
nextDepsById.set(targetId, deps);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
if (createsCycle(state.items, nextDepsById)) {
|
|
793
|
-
return error(state, "blockedBy would create a cycle");
|
|
794
|
-
}
|
|
795
|
-
// Readiness gates explicit status changes. An already active task that
|
|
796
|
-
// takes on a new incomplete edge is instead automatically demoted below.
|
|
797
|
-
if ((nextStatus === "in_progress" || nextStatus === "completed") && current.status !== nextStatus) {
|
|
798
|
-
const readyError = validateDependenciesReady(state, nextDeps);
|
|
799
|
-
if (readyError)
|
|
800
|
-
return error(state, readyError);
|
|
801
|
-
}
|
|
802
|
-
const updated = { ...current, status: nextStatus };
|
|
803
|
-
if (params.subject !== undefined)
|
|
804
|
-
updated.subject = params.subject.trim();
|
|
805
|
-
// Empty string removes an optional text field (metadata uses null keys).
|
|
806
|
-
if (params.description !== undefined) {
|
|
807
|
-
if (params.description)
|
|
808
|
-
updated.description = params.description;
|
|
809
|
-
else
|
|
810
|
-
delete updated.description;
|
|
811
|
-
}
|
|
812
|
-
if (params.activeForm !== undefined) {
|
|
813
|
-
if (params.activeForm)
|
|
814
|
-
updated.activeForm = params.activeForm;
|
|
815
|
-
else
|
|
816
|
-
delete updated.activeForm;
|
|
817
|
-
}
|
|
818
|
-
if (params.owner !== undefined) {
|
|
819
|
-
if (params.owner)
|
|
820
|
-
updated.owner = params.owner;
|
|
821
|
-
else
|
|
822
|
-
delete updated.owner;
|
|
823
|
-
}
|
|
824
|
-
if (nextDeps.length)
|
|
825
|
-
updated.blockedBy = nextDeps;
|
|
826
|
-
else
|
|
827
|
-
delete updated.blockedBy;
|
|
828
|
-
if (params.metadata !== undefined) {
|
|
829
|
-
if (metadata)
|
|
830
|
-
updated.metadata = metadata;
|
|
831
|
-
else
|
|
832
|
-
delete updated.metadata;
|
|
833
|
-
}
|
|
834
|
-
const items = state.items.map((task) => {
|
|
835
|
-
if (task.id === current.id)
|
|
836
|
-
return updated;
|
|
837
|
-
const deps = nextDepsById.get(task.id);
|
|
838
|
-
if (deps === undefined)
|
|
839
|
-
return task;
|
|
840
|
-
return deps.length ? { ...task, blockedBy: deps } : omitBlockedBy(task);
|
|
841
|
-
});
|
|
842
|
-
// A task that was already active may become newly blocked through either
|
|
843
|
-
// direction of an edge edit. Keep it out of in_progress and distinguish
|
|
844
|
-
// this from the usual single-active-task demotion in the result.
|
|
845
|
-
const blockedIds = [];
|
|
846
|
-
for (const [targetId, deps] of nextDepsById) {
|
|
847
|
-
const before = findItem(state, targetId);
|
|
848
|
-
const targetIndex = items.findIndex((item) => item.id === targetId);
|
|
849
|
-
if (!before || targetIndex === -1 || items[targetIndex].status !== "in_progress")
|
|
850
|
-
continue;
|
|
851
|
-
const previousDeps = before.blockedBy ?? [];
|
|
852
|
-
const gainedUnresolved = deps.some((dependencyId) => {
|
|
853
|
-
if (previousDeps.includes(dependencyId))
|
|
854
|
-
return false;
|
|
855
|
-
const dependency = items.find((item) => item.id === dependencyId);
|
|
856
|
-
return dependency?.status !== "completed";
|
|
857
|
-
});
|
|
858
|
-
if (gainedUnresolved) {
|
|
859
|
-
items[targetIndex] = { ...items[targetIndex], status: "pending" };
|
|
860
|
-
blockedIds.push(targetId);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
// Exactly one in_progress: demote any other active tasks to pending.
|
|
864
|
-
const demotedIds = [];
|
|
865
|
-
const finalCurrent = items[index];
|
|
866
|
-
if (nextStatus === "in_progress" && finalCurrent.status === "in_progress") {
|
|
867
|
-
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
|
868
|
-
const item = items[itemIndex];
|
|
869
|
-
if (item.id === finalCurrent.id || item.status !== "in_progress")
|
|
870
|
-
continue;
|
|
871
|
-
items[itemIndex] = { ...item, status: "pending" };
|
|
872
|
-
demotedIds.push(item.id);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
const nextState = { items, nextId: state.nextId };
|
|
876
|
-
const finalItem = items[index];
|
|
877
|
-
const newlyCompleted = current.status !== "completed" && finalItem.status === "completed";
|
|
878
|
-
return {
|
|
879
|
-
state: nextState,
|
|
880
|
-
operation: {
|
|
881
|
-
kind: "update",
|
|
882
|
-
id: finalItem.id,
|
|
883
|
-
from: current.status,
|
|
884
|
-
to: finalItem.status,
|
|
885
|
-
...(demotedIds.length ? { demotedIds } : {}),
|
|
886
|
-
...(blockedIds.length ? { blockedIds } : {}),
|
|
887
|
-
...(newlyCompleted && needsVerificationNudge(nextState) ? { verificationNudge: true } : {}),
|
|
888
|
-
},
|
|
889
|
-
};
|
|
890
|
-
}
|
|
891
|
-
case "list": {
|
|
892
|
-
if (params.status !== undefined && !isTodoStatus(params.status))
|
|
893
|
-
return error(state, "status is invalid");
|
|
894
|
-
if (params.includeDeleted !== undefined && typeof params.includeDeleted !== "boolean") {
|
|
895
|
-
return error(state, "includeDeleted must be a boolean");
|
|
896
|
-
}
|
|
897
|
-
if (params.unblockedOnly !== undefined && typeof params.unblockedOnly !== "boolean") {
|
|
898
|
-
return error(state, "unblockedOnly must be a boolean");
|
|
899
|
-
}
|
|
900
|
-
if (params.limit !== undefined &&
|
|
901
|
-
(!Number.isSafeInteger(params.limit) || params.limit < 1 || params.limit > TODO_MAX_LIST_LIMIT)) {
|
|
902
|
-
return error(state, `limit must be an integer from 1 to ${TODO_MAX_LIST_LIMIT}`);
|
|
903
|
-
}
|
|
904
|
-
if (params.afterId !== undefined && !isPositiveInteger(params.afterId))
|
|
905
|
-
return error(state, "afterId must be a positive integer");
|
|
906
|
-
if (params.query !== undefined) {
|
|
907
|
-
const queryError = validateBoundedString(params.query, "query", TODO_MAX_LIST_QUERY_LENGTH);
|
|
908
|
-
if (queryError)
|
|
909
|
-
return error(state, queryError);
|
|
910
|
-
}
|
|
196
|
+
const id = params.id;
|
|
197
|
+
if (!isPositiveSafeInteger(id))
|
|
198
|
+
throw new Error("id required for update");
|
|
199
|
+
const demotedId = before.items.find((item) => item.id !== id && item.status === "in_progress" && findItem(after, item.id)?.status === "pending")?.id;
|
|
911
200
|
return {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
limit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,
|
|
918
|
-
...(params.afterId !== undefined ? { afterId: params.afterId } : {}),
|
|
919
|
-
...(params.query !== undefined ? { query: params.query } : {}),
|
|
920
|
-
unblockedOnly: params.unblockedOnly === true,
|
|
921
|
-
legacyOutput: params.limit === undefined &&
|
|
922
|
-
params.afterId === undefined &&
|
|
923
|
-
params.query === undefined &&
|
|
924
|
-
params.unblockedOnly === undefined,
|
|
925
|
-
},
|
|
201
|
+
kind: "update",
|
|
202
|
+
id,
|
|
203
|
+
from: findItem(before, id)?.status ?? "pending",
|
|
204
|
+
to: findItem(after, id)?.status ?? "pending",
|
|
205
|
+
...(demotedId !== undefined ? { demotedId } : {}),
|
|
926
206
|
};
|
|
927
207
|
}
|
|
928
|
-
case "
|
|
929
|
-
|
|
930
|
-
return error(state, "id required for get");
|
|
931
|
-
const item = findItem(state, params.id);
|
|
932
|
-
if (!item)
|
|
933
|
-
return error(state, `#${params.id} not found`);
|
|
934
|
-
return { state, operation: { kind: "get", item } };
|
|
935
|
-
}
|
|
208
|
+
case "list":
|
|
209
|
+
return { kind: "list" };
|
|
936
210
|
case "delete": {
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
if (current.status === "deleted")
|
|
944
|
-
return error(state, `#${params.id} is already deleted`);
|
|
945
|
-
const deleted = markDeleted(state, current.id);
|
|
946
|
-
return {
|
|
947
|
-
state: deleted.state,
|
|
948
|
-
operation: {
|
|
949
|
-
kind: "delete",
|
|
950
|
-
id: current.id,
|
|
951
|
-
subject: current.subject,
|
|
952
|
-
...(deleted.releasedIds.length ? { releasedIds: deleted.releasedIds } : {}),
|
|
953
|
-
},
|
|
954
|
-
};
|
|
211
|
+
const ids = Array.isArray(params.ids) ? dedupeIds(params.ids) : [];
|
|
212
|
+
const removed = ids
|
|
213
|
+
.map((id) => findItem(before, id))
|
|
214
|
+
.filter((item) => item !== undefined)
|
|
215
|
+
.map((item) => ({ id: item.id, subject: item.subject }));
|
|
216
|
+
return { kind: "delete", removed };
|
|
955
217
|
}
|
|
956
|
-
case "clear":
|
|
957
|
-
if (params.confirm !== true)
|
|
958
|
-
return error(state, "clear requires confirm: true");
|
|
959
|
-
if (!Number.isSafeInteger(params.expectedCount) || params.expectedCount !== state.items.length) {
|
|
960
|
-
return error(state, `clear expectedCount must equal current task count (${state.items.length})`);
|
|
961
|
-
}
|
|
962
|
-
return {
|
|
963
|
-
// IDs remain monotonic across a clear so stale references cannot point
|
|
964
|
-
// at an unrelated task created later on the same conversation branch.
|
|
965
|
-
state: { items: [], nextId: state.nextId },
|
|
966
|
-
operation: { kind: "clear", count: state.items.length },
|
|
967
|
-
};
|
|
968
218
|
}
|
|
969
219
|
}
|
|
970
|
-
function
|
|
971
|
-
|
|
972
|
-
return false;
|
|
973
|
-
for (const [status, count] of Object.entries(value)) {
|
|
974
|
-
if (!isTodoStatus(status) || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0)
|
|
975
|
-
return false;
|
|
976
|
-
}
|
|
977
|
-
return true;
|
|
220
|
+
export function buildTodoDetails(change, state) {
|
|
221
|
+
return { schemaVersion: TODO_DETAILS_SCHEMA_VERSION, change, state: cloneTodoState(state) };
|
|
978
222
|
}
|
|
979
|
-
function
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
case "create_many":
|
|
985
|
-
return Array.isArray(value.ids) && value.ids.every(isPositiveInteger);
|
|
986
|
-
case "update":
|
|
987
|
-
return isPositiveInteger(value.id) && isTodoStatus(value.status);
|
|
988
|
-
case "list":
|
|
989
|
-
return (typeof value.includeDeleted === "boolean" &&
|
|
990
|
-
typeof value.unblockedOnly === "boolean" &&
|
|
991
|
-
(value.status === undefined || isTodoStatus(value.status)) &&
|
|
992
|
-
(value.limit === undefined ||
|
|
993
|
-
(typeof value.limit === "number" && Number.isSafeInteger(value.limit) && value.limit >= 1)) &&
|
|
994
|
-
(value.afterId === undefined || isPositiveInteger(value.afterId)) &&
|
|
995
|
-
(value.query === undefined || typeof value.query === "string") &&
|
|
996
|
-
(value.statusCounts === undefined || isTodoStatusCounts(value.statusCounts)) &&
|
|
997
|
-
(value.resultCount === undefined ||
|
|
998
|
-
(typeof value.resultCount === "number" &&
|
|
999
|
-
Number.isSafeInteger(value.resultCount) &&
|
|
1000
|
-
value.resultCount >= 0)));
|
|
1001
|
-
case "get":
|
|
1002
|
-
case "delete":
|
|
1003
|
-
return isPositiveInteger(value.id);
|
|
1004
|
-
case "clear":
|
|
1005
|
-
return typeof value.count === "number" && Number.isSafeInteger(value.count) && value.count >= 0;
|
|
1006
|
-
}
|
|
223
|
+
function requireValidState(value) {
|
|
224
|
+
const state = normalizeSnapshotState(value);
|
|
225
|
+
if (!state)
|
|
226
|
+
throw new Error("todo state is invalid");
|
|
227
|
+
return state;
|
|
1007
228
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
229
|
+
/** Closure-scoped store: one list per store, no module-global session state. */
|
|
230
|
+
export function createTodoStore(initial) {
|
|
231
|
+
let state = requireValidState(initial ?? EMPTY_TODO_STATE);
|
|
232
|
+
return {
|
|
233
|
+
getState() {
|
|
234
|
+
return cloneTodoState(state);
|
|
235
|
+
},
|
|
236
|
+
replaceState(next) {
|
|
237
|
+
state = requireValidState(next);
|
|
238
|
+
},
|
|
239
|
+
execute(params) {
|
|
240
|
+
const before = state;
|
|
241
|
+
const after = applyTodoAction(before, params);
|
|
242
|
+
const details = buildTodoDetails(buildTodoChange(params, before, after), after);
|
|
243
|
+
state = after;
|
|
244
|
+
return details;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Validate an external v2 snapshot; returns undefined when malformed. */
|
|
249
|
+
function normalizeSnapshotState(value) {
|
|
250
|
+
if (!isRecord(value) || !Array.isArray(value.items) || !isPositiveSafeInteger(value.nextId))
|
|
251
|
+
return undefined;
|
|
252
|
+
if (value.items.length > TODO_MAX_ITEMS)
|
|
1010
253
|
return undefined;
|
|
1011
254
|
const items = [];
|
|
1012
255
|
const ids = new Set();
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
256
|
+
let maxId = 0;
|
|
257
|
+
let activeCount = 0;
|
|
258
|
+
for (const rawItem of value.items) {
|
|
259
|
+
if (!isRecord(rawItem))
|
|
1016
260
|
return undefined;
|
|
1017
|
-
|
|
261
|
+
const id = rawItem.id;
|
|
262
|
+
if (!isPositiveSafeInteger(id) || ids.has(id))
|
|
1018
263
|
return undefined;
|
|
1019
|
-
|
|
264
|
+
const subject = rawItem.subject;
|
|
265
|
+
const description = rawItem.description;
|
|
266
|
+
const status = rawItem.status;
|
|
267
|
+
if (typeof subject !== "string" || typeof description !== "string")
|
|
1020
268
|
return undefined;
|
|
1021
|
-
if (
|
|
269
|
+
if (subject !== normalizeText(subject) || description !== normalizeText(description))
|
|
1022
270
|
return undefined;
|
|
1023
|
-
if (
|
|
271
|
+
if (!subject || subject.length > TODO_MAX_SUBJECT_LENGTH)
|
|
1024
272
|
return undefined;
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
if (
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
const item = { id: rawItem.id, subject: rawItem.subject, status: rawItem.status };
|
|
1039
|
-
if (rawItem.description !== undefined)
|
|
1040
|
-
item.description = rawItem.description;
|
|
1041
|
-
if (rawItem.activeForm !== undefined)
|
|
1042
|
-
item.activeForm = rawItem.activeForm;
|
|
1043
|
-
if (blockedBy?.length)
|
|
1044
|
-
item.blockedBy = blockedBy;
|
|
1045
|
-
if (rawItem.owner !== undefined)
|
|
1046
|
-
item.owner = rawItem.owner;
|
|
1047
|
-
if (metadata)
|
|
1048
|
-
item.metadata = metadata;
|
|
1049
|
-
items.push(item);
|
|
1050
|
-
itemsById.set(item.id, item);
|
|
1051
|
-
ids.add(item.id);
|
|
1052
|
-
}
|
|
1053
|
-
const highestId = items.reduce((highest, item) => Math.max(highest, item.id), 0);
|
|
1054
|
-
if (nextIdValue <= highestId)
|
|
1055
|
-
return undefined;
|
|
1056
|
-
for (const item of items) {
|
|
1057
|
-
for (const dependencyId of item.blockedBy ?? []) {
|
|
1058
|
-
const dependency = itemsById.get(dependencyId);
|
|
1059
|
-
if (!dependency || dependency.status === "deleted")
|
|
1060
|
-
return undefined;
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
if (createsCycle(items, new Map()))
|
|
273
|
+
if (!description || description.length > TODO_MAX_DESCRIPTION_LENGTH)
|
|
274
|
+
return undefined;
|
|
275
|
+
if (!isTodoStatus(status))
|
|
276
|
+
return undefined;
|
|
277
|
+
ids.add(id);
|
|
278
|
+
if (id > maxId)
|
|
279
|
+
maxId = id;
|
|
280
|
+
if (status === "in_progress")
|
|
281
|
+
activeCount++;
|
|
282
|
+
items.push({ id, subject, description, status });
|
|
283
|
+
}
|
|
284
|
+
if (activeCount > 1)
|
|
1064
285
|
return undefined;
|
|
1065
|
-
|
|
1066
|
-
// blocked" state. Normalize it to the current invariant rather than losing
|
|
1067
|
-
// the newest otherwise-valid task list during replay.
|
|
1068
|
-
for (const item of items) {
|
|
1069
|
-
if (item.status !== "in_progress")
|
|
1070
|
-
continue;
|
|
1071
|
-
if ((item.blockedBy ?? []).some((id) => itemsById.get(id)?.status !== "completed")) {
|
|
1072
|
-
item.status = "pending";
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
if (items.filter((item) => item.status === "in_progress").length > 1)
|
|
286
|
+
if (value.nextId <= maxId)
|
|
1076
287
|
return undefined;
|
|
1077
|
-
items
|
|
1078
|
-
return { items, nextId: nextIdValue };
|
|
1079
|
-
}
|
|
1080
|
-
function normalizeDetails(value) {
|
|
1081
|
-
if (!isPlainRecord(value) || !isTodoAction(value.action) || !isPlainRecord(value.params))
|
|
1082
|
-
return undefined;
|
|
1083
|
-
if (value.schemaVersion !== undefined) {
|
|
1084
|
-
if (value.schemaVersion !== TODO_DETAILS_SCHEMA_VERSION || !isOperationSummary(value.operation, value.action)) {
|
|
1085
|
-
return undefined;
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
return normalizeSnapshotState(value.items, value.nextId);
|
|
288
|
+
return { items, nextId: value.nextId };
|
|
1089
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Replay the newest valid v2 todo snapshot from a branch, scanning tail to
|
|
292
|
+
* head so a malformed latest snapshot falls back to an earlier valid one.
|
|
293
|
+
* v1 details are ignored, and the `change` field is not validated.
|
|
294
|
+
*/
|
|
1090
295
|
export function replayTodosFromBranch(ctx) {
|
|
1091
|
-
// Latest valid todo toolResult wins; scan tail → head so malformed latest
|
|
1092
|
-
// snapshots cannot poison a branch that still has an earlier valid state.
|
|
1093
296
|
const branch = Array.from(ctx.sessionManager.getBranch());
|
|
1094
297
|
for (let index = branch.length - 1; index >= 0; index--) {
|
|
1095
298
|
const entry = branch[index];
|
|
1096
|
-
if (entry.type !== "message")
|
|
299
|
+
if (!isRecord(entry) || entry.type !== "message")
|
|
300
|
+
continue;
|
|
301
|
+
const message = entry.message;
|
|
302
|
+
if (!isRecord(message) || message.role !== "toolResult" || message.toolName !== TODO_TOOL_NAME)
|
|
1097
303
|
continue;
|
|
1098
|
-
|
|
304
|
+
const details = message.details;
|
|
305
|
+
if (!isRecord(details) || details.schemaVersion !== TODO_DETAILS_SCHEMA_VERSION)
|
|
1099
306
|
continue;
|
|
1100
|
-
let state;
|
|
1101
307
|
try {
|
|
1102
|
-
state =
|
|
308
|
+
const state = normalizeSnapshotState(details.state);
|
|
309
|
+
if (state)
|
|
310
|
+
return state;
|
|
1103
311
|
}
|
|
1104
312
|
catch {
|
|
1105
|
-
// Session history is external input;
|
|
1106
|
-
continue;
|
|
313
|
+
// Session history is external input; keep scanning for an earlier valid v2.
|
|
1107
314
|
}
|
|
1108
|
-
if (state)
|
|
1109
|
-
return cloneState(state);
|
|
1110
315
|
}
|
|
1111
|
-
return
|
|
1112
|
-
}
|
|
1113
|
-
function cloneDetailsParams(params) {
|
|
1114
|
-
const copy = {};
|
|
1115
|
-
for (const [key, value] of Object.entries(params)) {
|
|
1116
|
-
if (value === undefined)
|
|
1117
|
-
continue;
|
|
1118
|
-
if (key === "metadata") {
|
|
1119
|
-
const metadata = validateAndCloneMetadata(value, LEGACY_METADATA_LIMITS);
|
|
1120
|
-
if ("metadata" in metadata)
|
|
1121
|
-
copy[key] = metadata.metadata;
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
if (key === "items" && Array.isArray(value)) {
|
|
1125
|
-
copy[key] = value.map((item) => {
|
|
1126
|
-
if (!isPlainRecord(item))
|
|
1127
|
-
return item;
|
|
1128
|
-
const itemCopy = { ...item };
|
|
1129
|
-
if (item.metadata !== undefined) {
|
|
1130
|
-
const metadata = validateAndCloneMetadata(item.metadata, LEGACY_METADATA_LIMITS);
|
|
1131
|
-
if ("metadata" in metadata)
|
|
1132
|
-
itemCopy.metadata = metadata.metadata;
|
|
1133
|
-
}
|
|
1134
|
-
return itemCopy;
|
|
1135
|
-
});
|
|
1136
|
-
continue;
|
|
1137
|
-
}
|
|
1138
|
-
copy[key] = value;
|
|
1139
|
-
}
|
|
1140
|
-
return copy;
|
|
1141
|
-
}
|
|
1142
|
-
function buildTodoOperationSummary(params, next) {
|
|
1143
|
-
switch (params.action) {
|
|
1144
|
-
case "create":
|
|
1145
|
-
return { kind: "create", ids: [next.nextId - 1] };
|
|
1146
|
-
case "create_many": {
|
|
1147
|
-
const count = Array.isArray(params.items) ? params.items.length : 0;
|
|
1148
|
-
const firstId = next.nextId - count;
|
|
1149
|
-
return { kind: "create_many", ids: Array.from({ length: count }, (_, index) => firstId + index) };
|
|
1150
|
-
}
|
|
1151
|
-
case "update": {
|
|
1152
|
-
const item = findItem(next, params.id);
|
|
1153
|
-
return { kind: "update", id: params.id, status: item?.status ?? "pending" };
|
|
1154
|
-
}
|
|
1155
|
-
case "list": {
|
|
1156
|
-
const listOperation = {
|
|
1157
|
-
kind: "list",
|
|
1158
|
-
...(params.status !== undefined ? { status: params.status } : {}),
|
|
1159
|
-
includeDeleted: params.includeDeleted === true,
|
|
1160
|
-
limit: params.limit ?? LIST_DISPLAY_MAX_ITEMS,
|
|
1161
|
-
...(params.afterId !== undefined ? { afterId: params.afterId } : {}),
|
|
1162
|
-
...(params.query !== undefined ? { query: params.query } : {}),
|
|
1163
|
-
unblockedOnly: params.unblockedOnly === true,
|
|
1164
|
-
legacyOutput: false,
|
|
1165
|
-
};
|
|
1166
|
-
const shown = getListView(listOperation, next).slice(0, listOperation.limit);
|
|
1167
|
-
const statusCounts = {};
|
|
1168
|
-
for (const item of shown)
|
|
1169
|
-
statusCounts[item.status] = (statusCounts[item.status] ?? 0) + 1;
|
|
1170
|
-
return {
|
|
1171
|
-
kind: "list",
|
|
1172
|
-
...(listOperation.status !== undefined ? { status: listOperation.status } : {}),
|
|
1173
|
-
includeDeleted: listOperation.includeDeleted,
|
|
1174
|
-
limit: listOperation.limit,
|
|
1175
|
-
...(listOperation.afterId !== undefined ? { afterId: listOperation.afterId } : {}),
|
|
1176
|
-
...(listOperation.query !== undefined ? { query: listOperation.query } : {}),
|
|
1177
|
-
unblockedOnly: listOperation.unblockedOnly,
|
|
1178
|
-
statusCounts,
|
|
1179
|
-
resultCount: shown.length,
|
|
1180
|
-
};
|
|
1181
|
-
}
|
|
1182
|
-
case "get":
|
|
1183
|
-
return { kind: "get", id: params.id };
|
|
1184
|
-
case "delete":
|
|
1185
|
-
return { kind: "delete", id: params.id };
|
|
1186
|
-
case "clear":
|
|
1187
|
-
return { kind: "clear", count: params.expectedCount };
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
export function buildTodoDetails(params, next) {
|
|
1191
|
-
const snapshot = cloneState(next);
|
|
1192
|
-
return {
|
|
1193
|
-
schemaVersion: TODO_DETAILS_SCHEMA_VERSION,
|
|
1194
|
-
action: params.action,
|
|
1195
|
-
params: cloneDetailsParams(params),
|
|
1196
|
-
operation: buildTodoOperationSummary(params, snapshot),
|
|
1197
|
-
items: snapshot.items,
|
|
1198
|
-
nextId: snapshot.nextId,
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
const VERIFICATION_NUDGE_NOTE = "NOTE: You closed out 3+ tasks with no verification/test/review step. Before the final summary, run checks or add a verification task and complete it — do not self-declare done with known failures.";
|
|
1202
|
-
function formatReleasedSuffix(releasedIds) {
|
|
1203
|
-
if (!releasedIds?.length)
|
|
1204
|
-
return "";
|
|
1205
|
-
return `; released ${releasedIds.map((id) => `#${id}`).join(",")} (no longer blocked)`;
|
|
1206
|
-
}
|
|
1207
|
-
function formatDisplayText(value, maximum) {
|
|
1208
|
-
return value.length > maximum ? `${value.slice(0, maximum)}…` : value;
|
|
1209
|
-
}
|
|
1210
|
-
function getListView(operation, state) {
|
|
1211
|
-
let view = state.items;
|
|
1212
|
-
// An explicit deleted-status query is sufficient intent to include tombstones.
|
|
1213
|
-
if (!operation.includeDeleted && operation.status !== "deleted") {
|
|
1214
|
-
view = view.filter((item) => item.status !== "deleted");
|
|
1215
|
-
}
|
|
1216
|
-
if (operation.status)
|
|
1217
|
-
view = view.filter((item) => item.status === operation.status);
|
|
1218
|
-
if (operation.afterId !== undefined) {
|
|
1219
|
-
const afterId = operation.afterId;
|
|
1220
|
-
view = view.filter((item) => item.id > afterId);
|
|
1221
|
-
}
|
|
1222
|
-
if (operation.query !== undefined) {
|
|
1223
|
-
const query = operation.query.toLocaleLowerCase();
|
|
1224
|
-
view = view.filter((item) => item.subject.toLocaleLowerCase().includes(query) || item.description?.toLocaleLowerCase().includes(query));
|
|
1225
|
-
}
|
|
1226
|
-
if (operation.unblockedOnly)
|
|
1227
|
-
view = view.filter((item) => dependenciesSatisfied(state, item));
|
|
1228
|
-
return [...view].sort((first, second) => first.id - second.id);
|
|
1229
|
-
}
|
|
1230
|
-
export function formatTodoContent(operation, state) {
|
|
1231
|
-
switch (operation.kind) {
|
|
1232
|
-
case "create": {
|
|
1233
|
-
const item = findItem(state, operation.id);
|
|
1234
|
-
return item
|
|
1235
|
-
? `Created #${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (pending)`
|
|
1236
|
-
: `Created #${operation.id}`;
|
|
1237
|
-
}
|
|
1238
|
-
case "create_many": {
|
|
1239
|
-
const noun = operation.ids.length === 1 ? "task" : "tasks";
|
|
1240
|
-
const lines = operation.ids.map((id) => {
|
|
1241
|
-
const item = findItem(state, id);
|
|
1242
|
-
return item
|
|
1243
|
-
? `#${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (${item.status})`
|
|
1244
|
-
: `#${id}`;
|
|
1245
|
-
});
|
|
1246
|
-
return `Created ${operation.ids.length} ${noun}:${lines.length ? `\n${lines.join("\n")}` : ""}`;
|
|
1247
|
-
}
|
|
1248
|
-
case "update": {
|
|
1249
|
-
const transition = operation.from === operation.to ? "" : ` (${operation.from} -> ${operation.to})`;
|
|
1250
|
-
let text = `Updated #${operation.id}${transition}`;
|
|
1251
|
-
if (operation.demotedIds?.length) {
|
|
1252
|
-
text += `; ${operation.demotedIds.map((id) => `#${id}`).join(",")} demoted to pending`;
|
|
1253
|
-
}
|
|
1254
|
-
if (operation.blockedIds?.length) {
|
|
1255
|
-
text += `; ${operation.blockedIds.map((id) => `#${id}`).join(",")} moved to pending (dependencies incomplete)`;
|
|
1256
|
-
}
|
|
1257
|
-
text += formatReleasedSuffix(operation.releasedIds);
|
|
1258
|
-
if (operation.verificationNudge)
|
|
1259
|
-
text += `\n\n${VERIFICATION_NUDGE_NOTE}`;
|
|
1260
|
-
return text;
|
|
1261
|
-
}
|
|
1262
|
-
case "delete":
|
|
1263
|
-
return `Deleted #${operation.id}: ${formatDisplayText(operation.subject, TODO_MAX_SUBJECT_LENGTH)}${formatReleasedSuffix(operation.releasedIds)}`;
|
|
1264
|
-
case "clear":
|
|
1265
|
-
return `Cleared ${operation.count} tasks`;
|
|
1266
|
-
case "list":
|
|
1267
|
-
return formatBoundedList(getListView(operation, state), state, operation);
|
|
1268
|
-
case "get":
|
|
1269
|
-
return formatDetailItem(operation.item, state);
|
|
1270
|
-
case "error":
|
|
1271
|
-
return `Error: ${operation.message}`;
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
/**
|
|
1275
|
-
* Soft nudge when the whole list is done (no active work), there are 3+ completed
|
|
1276
|
-
* tasks, and none look like a verification/test/review step — mirrors CC TodoWrite.
|
|
1277
|
-
*/
|
|
1278
|
-
function needsVerificationNudge(state) {
|
|
1279
|
-
const active = state.items.filter((item) => item.status !== "deleted");
|
|
1280
|
-
if (active.length < 3)
|
|
1281
|
-
return false;
|
|
1282
|
-
if (active.some((item) => item.status === "pending" || item.status === "in_progress"))
|
|
1283
|
-
return false;
|
|
1284
|
-
const completed = active.filter((item) => item.status === "completed");
|
|
1285
|
-
if (completed.length < 3)
|
|
1286
|
-
return false;
|
|
1287
|
-
return !completed.some((item) => VERIFICATION_PATTERN.test(item.subject) ||
|
|
1288
|
-
(item.description !== undefined && VERIFICATION_PATTERN.test(item.description)));
|
|
1289
|
-
}
|
|
1290
|
-
function formatBoundedList(view, state, operation) {
|
|
1291
|
-
if (!view.length)
|
|
1292
|
-
return "No tasks";
|
|
1293
|
-
const shown = view.slice(0, operation.limit);
|
|
1294
|
-
const lines = shown.map((item) => formatListItem(item, state));
|
|
1295
|
-
const omitted = view.length - shown.length;
|
|
1296
|
-
if (omitted > 0) {
|
|
1297
|
-
if (!operation.legacyOutput) {
|
|
1298
|
-
lines.push(`… and ${omitted} more; next page: afterId=${shown.at(-1)?.id}.`);
|
|
1299
|
-
}
|
|
1300
|
-
else {
|
|
1301
|
-
// Do not suggest a filter that is already applied.
|
|
1302
|
-
const hint = operation.status ? "use get with id= for details" : "narrow with status= or use get with id=";
|
|
1303
|
-
lines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
return lines.join("\n");
|
|
1307
|
-
}
|
|
1308
|
-
function formatListIdList(ids) {
|
|
1309
|
-
const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
|
|
1310
|
-
return ids.length > shown.length ? `${shown.join(",")},…+${ids.length - shown.length}` : shown.join(",");
|
|
1311
|
-
}
|
|
1312
|
-
function formatListItem(item, state) {
|
|
1313
|
-
const unresolved = unresolvedDependencyIds(state, item);
|
|
1314
|
-
const deps = unresolved.length ? ` blockedBy=${formatListIdList(unresolved)}` : "";
|
|
1315
|
-
const owner = item.owner ? ` @${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}` : "";
|
|
1316
|
-
const active = item.status === "in_progress" && item.activeForm
|
|
1317
|
-
? ` (${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)})`
|
|
1318
|
-
: "";
|
|
1319
|
-
return `[${item.status}] #${item.id} ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}${active}${owner}${deps}`;
|
|
1320
|
-
}
|
|
1321
|
-
function formatIdList(ids) {
|
|
1322
|
-
const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
|
|
1323
|
-
return ids.length > shown.length ? `${shown.join(", ")}, … +${ids.length - shown.length}` : shown.join(", ");
|
|
1324
|
-
}
|
|
1325
|
-
function formatDetailItem(item, state) {
|
|
1326
|
-
const lines = [`#${item.id} [${item.status}] ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}`];
|
|
1327
|
-
if (item.description)
|
|
1328
|
-
lines.push(`description: ${formatDisplayText(item.description, TODO_MAX_DESCRIPTION_LENGTH)}`);
|
|
1329
|
-
if (item.activeForm)
|
|
1330
|
-
lines.push(`activeForm: ${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)}`);
|
|
1331
|
-
if (item.blockedBy?.length)
|
|
1332
|
-
lines.push(`blockedBy: ${formatIdList(item.blockedBy)}`);
|
|
1333
|
-
const blocks = state.items
|
|
1334
|
-
.filter((candidate) => candidate.blockedBy?.includes(item.id))
|
|
1335
|
-
.map((candidate) => candidate.id);
|
|
1336
|
-
if (blocks.length)
|
|
1337
|
-
lines.push(`blocks: ${formatIdList(blocks)}`);
|
|
1338
|
-
if (item.owner)
|
|
1339
|
-
lines.push(`owner: ${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}`);
|
|
1340
|
-
return lines.join("\n");
|
|
316
|
+
return cloneTodoState(EMPTY_TODO_STATE);
|
|
1341
317
|
}
|
|
1342
318
|
//# sourceMappingURL=state.js.map
|