@astralyn/pi 0.82.4 → 0.82.6
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 +52 -0
- package/dist/cli/project-trust.d.ts.map +1 -1
- package/dist/cli/project-trust.js +4 -1
- package/dist/cli/project-trust.js.map +1 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +2 -2
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +4 -22
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/extensions/index.d.ts +2 -2
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/loader.d.ts +1 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +2 -3
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/types.d.ts +37 -5
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js +11 -0
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts +3 -13
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +6 -36
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/extensions/plan/constants.d.ts +11 -2
- package/dist/extensions/plan/constants.d.ts.map +1 -1
- package/dist/extensions/plan/constants.js +14 -2
- package/dist/extensions/plan/constants.js.map +1 -1
- package/dist/extensions/plan/index.d.ts +0 -18
- package/dist/extensions/plan/index.d.ts.map +1 -1
- package/dist/extensions/plan/index.js +35 -3
- package/dist/extensions/plan/index.js.map +1 -1
- package/dist/extensions/plan/view.d.ts +36 -0
- package/dist/extensions/plan/view.d.ts.map +1 -0
- package/dist/extensions/plan/view.js +74 -0
- package/dist/extensions/plan/view.js.map +1 -0
- package/dist/extensions/question/dialog.d.ts +3 -1
- package/dist/extensions/question/dialog.d.ts.map +1 -1
- package/dist/extensions/question/dialog.js +106 -52
- package/dist/extensions/question/dialog.js.map +1 -1
- package/dist/extensions/question/index.d.ts.map +1 -1
- package/dist/extensions/question/index.js +2 -2
- package/dist/extensions/question/index.js.map +1 -1
- package/dist/extensions/question/render-cache.d.ts +9 -0
- package/dist/extensions/question/render-cache.d.ts.map +1 -1
- package/dist/extensions/question/render-cache.js +22 -0
- package/dist/extensions/question/render-cache.js.map +1 -1
- package/dist/extensions/question/results.d.ts.map +1 -1
- package/dist/extensions/question/results.js +6 -1
- package/dist/extensions/question/results.js.map +1 -1
- package/dist/extensions/question/schema.d.ts +4 -0
- package/dist/extensions/question/schema.d.ts.map +1 -1
- package/dist/extensions/question/schema.js +19 -28
- package/dist/extensions/question/schema.js.map +1 -1
- package/dist/extensions/question/types.d.ts +1 -1
- package/dist/extensions/question/types.d.ts.map +1 -1
- package/dist/extensions/question/types.js.map +1 -1
- package/dist/extensions/router/stream.d.ts.map +1 -1
- package/dist/extensions/router/stream.js +44 -0
- package/dist/extensions/router/stream.js.map +1 -1
- package/dist/extensions/subagent/activity.d.ts.map +1 -1
- package/dist/extensions/subagent/activity.js +25 -4
- package/dist/extensions/subagent/activity.js.map +1 -1
- package/dist/extensions/subagent/agents.d.ts.map +1 -1
- package/dist/extensions/subagent/agents.js +12 -6
- package/dist/extensions/subagent/agents.js.map +1 -1
- package/dist/extensions/subagent/constants.d.ts +1 -1
- package/dist/extensions/subagent/constants.d.ts.map +1 -1
- package/dist/extensions/subagent/constants.js +3 -2
- package/dist/extensions/subagent/constants.js.map +1 -1
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +14 -0
- 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 +243 -116
- package/dist/extensions/subagent/render.js.map +1 -1
- package/dist/extensions/subagent/resolve.d.ts.map +1 -1
- package/dist/extensions/subagent/resolve.js +13 -9
- package/dist/extensions/subagent/resolve.js.map +1 -1
- package/dist/extensions/subagent/runner.d.ts +1 -1
- package/dist/extensions/subagent/runner.d.ts.map +1 -1
- package/dist/extensions/subagent/runner.js +9 -3
- package/dist/extensions/subagent/runner.js.map +1 -1
- package/dist/extensions/subagent/schema.d.ts.map +1 -1
- package/dist/extensions/subagent/schema.js +4 -4
- package/dist/extensions/subagent/schema.js.map +1 -1
- package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
- package/dist/extensions/subagent/sdk-runner.js +5 -0
- package/dist/extensions/subagent/sdk-runner.js.map +1 -1
- package/dist/extensions/subagent/types.d.ts +7 -0
- package/dist/extensions/subagent/types.d.ts.map +1 -1
- package/dist/extensions/subagent/types.js.map +1 -1
- package/dist/extensions/todo/constants.d.ts +17 -6
- package/dist/extensions/todo/constants.d.ts.map +1 -1
- package/dist/extensions/todo/constants.js +47 -9
- package/dist/extensions/todo/constants.js.map +1 -1
- package/dist/extensions/todo/index.d.ts +1 -15
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +35 -4
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +59 -7
- package/dist/extensions/todo/schema.d.ts.map +1 -1
- package/dist/extensions/todo/schema.js +72 -12
- package/dist/extensions/todo/schema.js.map +1 -1
- package/dist/extensions/todo/state.d.ts +20 -5
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +1016 -134
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts +16 -1
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +293 -7
- package/dist/extensions/todo/view.js.map +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts +6 -1
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js +14 -6
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +6 -0
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +2 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +41 -2
- 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 +8 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/utils/coalescing-terminal.d.ts +71 -17
- package/dist/utils/coalescing-terminal.d.ts.map +1 -1
- package/dist/utils/coalescing-terminal.js +136 -31
- package/dist/utils/coalescing-terminal.js.map +1 -1
- package/docs/bundled/extensions/plan.md +6 -4
- package/docs/bundled/extensions/question.md +9 -7
- package/docs/bundled/extensions/subagent.md +4 -4
- package/docs/bundled/extensions/todo.md +9 -5
- package/docs/bundled/tool-presentation.md +1 -1
- package/docs/extensions.md +14 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/utils/output-decoder.d.ts +0 -52
- package/dist/utils/output-decoder.d.ts.map +0 -1
- package/dist/utils/output-decoder.js +0 -134
- package/dist/utils/output-decoder.js.map +0 -1
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
import { LIST_DISPLAY_MAX_ITEMS, TODO_TOOL_NAME } from "./constants.js";
|
|
2
|
-
import { EMPTY_TODO_STATE, } from "./schema.js";
|
|
1
|
+
import { LIST_DISPLAY_MAX_ITEMS, TODO_MAX_ACTIVE_FORM_LENGTH, TODO_MAX_BATCH_ITEMS, TODO_MAX_BATCH_KEY_LENGTH, TODO_MAX_BLOCKED_BY, TODO_MAX_DESCRIPTION_LENGTH, TODO_MAX_LIST_LIMIT, TODO_MAX_LIST_QUERY_LENGTH, TODO_MAX_METADATA_BYTES, TODO_MAX_METADATA_DEPTH, TODO_MAX_METADATA_ENTRIES, TODO_MAX_METADATA_KEY_LENGTH, TODO_MAX_OWNER_LENGTH, TODO_MAX_SUBJECT_LENGTH, TODO_TOOL_NAME, } from "./constants.js";
|
|
2
|
+
import { EMPTY_TODO_STATE, TODO_DETAILS_SCHEMA_VERSION, } from "./schema.js";
|
|
3
3
|
/** Subjects/descriptions that count as a verification step (CC-style soft nudge). */
|
|
4
4
|
const VERIFICATION_PATTERN = /verif|test|check|review/i;
|
|
5
|
+
const TODO_STATUSES = new Set(["pending", "in_progress", "completed", "deleted"]);
|
|
6
|
+
const TODO_ACTIONS = new Set([
|
|
7
|
+
"create",
|
|
8
|
+
"create_many",
|
|
9
|
+
"update",
|
|
10
|
+
"list",
|
|
11
|
+
"get",
|
|
12
|
+
"delete",
|
|
13
|
+
"clear",
|
|
14
|
+
]);
|
|
15
|
+
const LEGACY_METADATA_LIMITS = {
|
|
16
|
+
maxDepth: 64,
|
|
17
|
+
maxEntries: 100_000,
|
|
18
|
+
maxBytes: 16 * 1024 * 1024,
|
|
19
|
+
};
|
|
5
20
|
// ---- per-session state ------------------------------------------------------
|
|
6
21
|
// Keyed by session id like rewind's engine state: one Pi process can host more
|
|
7
22
|
// than one session over its lifetime (resume, /tree branch switches), and a
|
|
@@ -14,8 +29,9 @@ let activeSid = "";
|
|
|
14
29
|
export function setActiveTodoSession(sid) {
|
|
15
30
|
activeSid = sid;
|
|
16
31
|
}
|
|
32
|
+
/** Snapshot of the active session's state; mutate via applyTodoMutation + commitTodoState. */
|
|
17
33
|
export function getTodoState() {
|
|
18
|
-
return states.get(activeSid) ??
|
|
34
|
+
return cloneState(states.get(activeSid) ?? EMPTY_TODO_STATE);
|
|
19
35
|
}
|
|
20
36
|
export function replaceTodoState(next) {
|
|
21
37
|
states.set(activeSid, cloneState(next));
|
|
@@ -31,6 +47,173 @@ export function disposeTodoSession(sid) {
|
|
|
31
47
|
if (activeSid === sid)
|
|
32
48
|
activeSid = "";
|
|
33
49
|
}
|
|
50
|
+
function isRecord(value) {
|
|
51
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
function isPlainRecord(value) {
|
|
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) {
|
|
60
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
61
|
+
}
|
|
62
|
+
function isTodoStatus(value) {
|
|
63
|
+
return typeof value === "string" && TODO_STATUSES.has(value);
|
|
64
|
+
}
|
|
65
|
+
function isTodoAction(value) {
|
|
66
|
+
return typeof value === "string" && TODO_ACTIONS.has(value);
|
|
67
|
+
}
|
|
68
|
+
function jsonStringUtf8Bytes(value) {
|
|
69
|
+
let bytes = 2; // JSON quotes
|
|
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
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function validateAndCloneMetadata(value, limits) {
|
|
201
|
+
if (!isPlainRecord(value))
|
|
202
|
+
return { error: "metadata must be a plain object" };
|
|
203
|
+
try {
|
|
204
|
+
const cloned = cloneJsonValue(value, 1, new WeakSet(), { entries: 0, bytes: 0 }, limits);
|
|
205
|
+
if (!isPlainRecord(cloned))
|
|
206
|
+
return { error: "metadata must be a plain object" };
|
|
207
|
+
return { metadata: cloned };
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
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;
|
|
216
|
+
}
|
|
34
217
|
export function cloneState(source) {
|
|
35
218
|
return {
|
|
36
219
|
items: source.items.map((item) => {
|
|
@@ -39,8 +222,13 @@ export function cloneState(source) {
|
|
|
39
222
|
clone.blockedBy = [...item.blockedBy];
|
|
40
223
|
else
|
|
41
224
|
delete clone.blockedBy;
|
|
42
|
-
if (item.metadata)
|
|
43
|
-
|
|
225
|
+
if (item.metadata) {
|
|
226
|
+
const metadata = cloneStoredMetadata(item.metadata);
|
|
227
|
+
if (metadata)
|
|
228
|
+
clone.metadata = metadata;
|
|
229
|
+
else
|
|
230
|
+
delete clone.metadata;
|
|
231
|
+
}
|
|
44
232
|
else
|
|
45
233
|
delete clone.metadata;
|
|
46
234
|
return clone;
|
|
@@ -53,7 +241,11 @@ function omitBlockedBy(item) {
|
|
|
53
241
|
delete next.blockedBy;
|
|
54
242
|
return next;
|
|
55
243
|
}
|
|
56
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Keep a tombstone while detaching its id from every dependent task. Also
|
|
246
|
+
* reports which pending dependents ended up fully unblocked, so the tool
|
|
247
|
+
* result can tell the model what became workable (mirrors the demote note).
|
|
248
|
+
*/
|
|
57
249
|
function markDeleted(state, id) {
|
|
58
250
|
const items = state.items.map((item) => {
|
|
59
251
|
if (item.id === id)
|
|
@@ -63,11 +255,115 @@ function markDeleted(state, id) {
|
|
|
63
255
|
const blockedBy = item.blockedBy.filter((dependencyId) => dependencyId !== id);
|
|
64
256
|
return blockedBy.length ? { ...item, blockedBy } : omitBlockedBy(item);
|
|
65
257
|
});
|
|
66
|
-
|
|
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 };
|
|
67
267
|
}
|
|
68
268
|
function error(state, message) {
|
|
69
269
|
return { state, operation: { kind: "error", message } };
|
|
70
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}`;
|
|
305
|
+
}
|
|
306
|
+
function findInapplicableParam(params) {
|
|
307
|
+
const allowed = ACTION_PARAMS[params.action];
|
|
308
|
+
for (const [key, value] of Object.entries(params)) {
|
|
309
|
+
if (key === "action" || value === undefined)
|
|
310
|
+
continue;
|
|
311
|
+
if (!allowed.has(key))
|
|
312
|
+
return inapplicableParamError(params.action, key);
|
|
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";
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
function validateIdArray(value, label) {
|
|
352
|
+
if (value === undefined)
|
|
353
|
+
return [];
|
|
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;
|
|
366
|
+
}
|
|
71
367
|
function isTransitionAllowed(from, to) {
|
|
72
368
|
// `deleted` is the only terminal status; `completed` can be reopened to
|
|
73
369
|
// in_progress or pending so a premature completion recovers without losing
|
|
@@ -90,8 +386,6 @@ function findItem(state, id) {
|
|
|
90
386
|
return state.items.find((item) => item.id === id);
|
|
91
387
|
}
|
|
92
388
|
function validateDependencies(state, deps, currentId) {
|
|
93
|
-
if (!deps?.length)
|
|
94
|
-
return undefined;
|
|
95
389
|
for (const dep of deps) {
|
|
96
390
|
if (dep === currentId)
|
|
97
391
|
return `cannot block #${currentId} on itself`;
|
|
@@ -105,8 +399,6 @@ function validateDependencies(state, deps, currentId) {
|
|
|
105
399
|
}
|
|
106
400
|
/** All listed dependencies must be completed before starting or closing a task. */
|
|
107
401
|
function validateDependenciesReady(state, deps) {
|
|
108
|
-
if (!deps?.length)
|
|
109
|
-
return undefined;
|
|
110
402
|
for (const dep of deps) {
|
|
111
403
|
const item = findItem(state, dep);
|
|
112
404
|
if (!item || item.status !== "completed") {
|
|
@@ -123,64 +415,255 @@ export function unresolvedDependencyIds(state, item) {
|
|
|
123
415
|
export function dependenciesSatisfied(state, item) {
|
|
124
416
|
return unresolvedDependencyIds(state, item).length === 0;
|
|
125
417
|
}
|
|
126
|
-
|
|
418
|
+
/** True when the resulting dependency graph contains a cycle. */
|
|
419
|
+
function createsCycle(items, nextDepsById) {
|
|
127
420
|
const depsById = new Map();
|
|
128
421
|
for (const item of items)
|
|
129
422
|
depsById.set(item.id, item.blockedBy ?? []);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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)
|
|
142
448
|
return true;
|
|
449
|
+
if (color === 2)
|
|
450
|
+
continue;
|
|
451
|
+
colors.set(dependencyId, 1);
|
|
452
|
+
stack.push({ id: dependencyId, nextDependencyIndex: 0 });
|
|
143
453
|
}
|
|
144
|
-
stack.delete(node);
|
|
145
|
-
return false;
|
|
146
454
|
}
|
|
147
|
-
return
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
function validateAndNormalizeBatch(params, state) {
|
|
458
|
+
const rawItems = params.items;
|
|
459
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0)
|
|
460
|
+
return "items required for create_many";
|
|
461
|
+
if (rawItems.length > TODO_MAX_BATCH_ITEMS)
|
|
462
|
+
return `items exceeds ${TODO_MAX_BATCH_ITEMS} tasks`;
|
|
463
|
+
const allowedKeys = new Set([
|
|
464
|
+
"key",
|
|
465
|
+
"subject",
|
|
466
|
+
"description",
|
|
467
|
+
"activeForm",
|
|
468
|
+
"blockedBy",
|
|
469
|
+
"blockedByKeys",
|
|
470
|
+
"owner",
|
|
471
|
+
"metadata",
|
|
472
|
+
]);
|
|
473
|
+
const batch = [];
|
|
474
|
+
const keys = new Map();
|
|
475
|
+
for (let index = 0; index < rawItems.length; index++) {
|
|
476
|
+
const raw = rawItems[index];
|
|
477
|
+
if (!isPlainRecord(raw))
|
|
478
|
+
return `items[${index}] must be an object`;
|
|
479
|
+
for (const property of Object.keys(raw)) {
|
|
480
|
+
if (!allowedKeys.has(property))
|
|
481
|
+
return `items[${index}].${property} does not apply to create_many`;
|
|
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;
|
|
536
|
+
}
|
|
537
|
+
batch.push({
|
|
538
|
+
...(key ? { key } : {}),
|
|
539
|
+
subject: raw.subject.trim(),
|
|
540
|
+
description: raw.description,
|
|
541
|
+
...(raw.activeForm ? { activeForm: raw.activeForm } : {}),
|
|
542
|
+
blockedBy,
|
|
543
|
+
blockedByKeys,
|
|
544
|
+
...(raw.owner ? { owner: raw.owner } : {}),
|
|
545
|
+
...(metadata ? { metadata } : {}),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
for (let index = 0; index < batch.length; index++) {
|
|
549
|
+
for (const key of batch[index].blockedByKeys) {
|
|
550
|
+
if (!keys.has(key))
|
|
551
|
+
return `items[${index}]: blockedByKeys references missing key ${key}`;
|
|
552
|
+
if (key === batch[index].key)
|
|
553
|
+
return `items[${index}]: cannot block a task on itself`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return batch;
|
|
557
|
+
}
|
|
558
|
+
function createMany(state, params) {
|
|
559
|
+
const batch = validateAndNormalizeBatch(params, state);
|
|
560
|
+
if (typeof batch === "string")
|
|
561
|
+
return error(state, batch);
|
|
562
|
+
if (!Number.isSafeInteger(state.nextId) ||
|
|
563
|
+
state.nextId < 1 ||
|
|
564
|
+
state.nextId + batch.length - 1 > Number.MAX_SAFE_INTEGER) {
|
|
565
|
+
return error(state, "next id is invalid or exhausted");
|
|
566
|
+
}
|
|
567
|
+
const idsByKey = new Map();
|
|
568
|
+
for (let index = 0; index < batch.length; index++) {
|
|
569
|
+
const key = batch[index].key;
|
|
570
|
+
if (key)
|
|
571
|
+
idsByKey.set(key, state.nextId + index);
|
|
572
|
+
}
|
|
573
|
+
const created = batch.map((source, index) => {
|
|
574
|
+
const blockedBy = [...source.blockedBy];
|
|
575
|
+
for (const key of source.blockedByKeys) {
|
|
576
|
+
const id = idsByKey.get(key);
|
|
577
|
+
if (id !== undefined && !blockedBy.includes(id))
|
|
578
|
+
blockedBy.push(id);
|
|
579
|
+
}
|
|
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
|
+
return item;
|
|
595
|
+
});
|
|
596
|
+
const batchEdges = new Map();
|
|
597
|
+
for (const item of created)
|
|
598
|
+
batchEdges.set(item.id, item.blockedBy ?? []);
|
|
599
|
+
if (createsCycle([...state.items, ...created], batchEdges))
|
|
600
|
+
return error(state, "blockedBy would create a cycle");
|
|
601
|
+
return {
|
|
602
|
+
state: { items: [...state.items, ...created], nextId: state.nextId + created.length },
|
|
603
|
+
operation: { kind: "create_many", ids: created.map((item) => item.id) },
|
|
604
|
+
};
|
|
148
605
|
}
|
|
149
606
|
export function applyTodoMutation(input, params) {
|
|
150
607
|
const state = cloneState(input);
|
|
608
|
+
if (!isTodoAction(params.action))
|
|
609
|
+
return error(state, `unknown action: ${String(params.action)}`);
|
|
610
|
+
const inapplicable = findInapplicableParam(params);
|
|
611
|
+
if (inapplicable)
|
|
612
|
+
return error(state, inapplicable);
|
|
151
613
|
switch (params.action) {
|
|
152
614
|
case "create": {
|
|
153
|
-
|
|
154
|
-
if (!subject)
|
|
615
|
+
if (typeof params.subject !== "string" || !params.subject.trim())
|
|
155
616
|
return error(state, "subject required for create");
|
|
617
|
+
const taskTextError = validateTaskTextFields(params, true);
|
|
618
|
+
if (taskTextError)
|
|
619
|
+
return error(state, taskTextError);
|
|
156
620
|
if (params.status !== undefined && params.status !== "pending") {
|
|
157
621
|
return error(state, "create always starts pending; use update to start or complete a task");
|
|
158
622
|
}
|
|
159
|
-
const
|
|
623
|
+
const blockedBy = validateIdArray(params.blockedBy, "blockedBy");
|
|
624
|
+
if (typeof blockedBy === "string")
|
|
625
|
+
return error(state, blockedBy);
|
|
626
|
+
const dependencyError = validateDependencies(state, blockedBy);
|
|
160
627
|
if (dependencyError)
|
|
161
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
|
+
}
|
|
162
644
|
const item = {
|
|
163
645
|
id: state.nextId,
|
|
164
|
-
subject,
|
|
646
|
+
subject: params.subject.trim(),
|
|
165
647
|
status: "pending",
|
|
648
|
+
description: params.description,
|
|
166
649
|
};
|
|
167
|
-
if (params.description)
|
|
168
|
-
item.description = params.description;
|
|
169
650
|
if (params.activeForm)
|
|
170
651
|
item.activeForm = params.activeForm;
|
|
171
|
-
if (
|
|
172
|
-
item.blockedBy =
|
|
652
|
+
if (blockedBy.length)
|
|
653
|
+
item.blockedBy = blockedBy;
|
|
173
654
|
if (params.owner)
|
|
174
655
|
item.owner = params.owner;
|
|
175
|
-
if (
|
|
176
|
-
item.metadata =
|
|
656
|
+
if (metadata)
|
|
657
|
+
item.metadata = metadata;
|
|
177
658
|
return {
|
|
178
659
|
state: { items: [...state.items, item], nextId: state.nextId + 1 },
|
|
179
660
|
operation: { kind: "create", id: item.id },
|
|
180
661
|
};
|
|
181
662
|
}
|
|
663
|
+
case "create_many":
|
|
664
|
+
return createMany(state, params);
|
|
182
665
|
case "update": {
|
|
183
|
-
if (params.id
|
|
666
|
+
if (!isPositiveInteger(params.id))
|
|
184
667
|
return error(state, "id required for update");
|
|
185
668
|
const index = state.items.findIndex((item) => item.id === params.id);
|
|
186
669
|
if (index === -1)
|
|
@@ -188,47 +671,129 @@ export function applyTodoMutation(input, params) {
|
|
|
188
671
|
const current = state.items[index];
|
|
189
672
|
if (current.status === "deleted")
|
|
190
673
|
return error(state, `#${params.id} is deleted and cannot be updated`);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 ||
|
|
194
693
|
params.description !== undefined ||
|
|
195
694
|
params.activeForm !== undefined ||
|
|
196
|
-
params.status !== undefined ||
|
|
197
695
|
params.owner !== undefined ||
|
|
198
|
-
params.metadata !== undefined
|
|
199
|
-
|
|
200
|
-
(params.removeBlockedBy?.length ?? 0) > 0;
|
|
201
|
-
if (!hasChange)
|
|
696
|
+
params.metadata !== undefined;
|
|
697
|
+
if (!fieldChange && !edgeChange && params.status === undefined) {
|
|
202
698
|
return error(state, "update requires at least one field");
|
|
203
|
-
|
|
204
|
-
// cleanup identical to the dedicated delete action.
|
|
699
|
+
}
|
|
205
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);
|
|
206
705
|
return {
|
|
207
|
-
state:
|
|
208
|
-
operation: {
|
|
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
|
+
},
|
|
209
714
|
};
|
|
210
715
|
}
|
|
211
716
|
const nextStatus = params.status ?? current.status;
|
|
212
717
|
if (!isTransitionAllowed(current.status, nextStatus)) {
|
|
213
718
|
return error(state, `illegal transition ${current.status} -> ${nextStatus}`);
|
|
214
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();
|
|
215
754
|
let nextDeps = current.blockedBy ? [...current.blockedBy] : [];
|
|
216
|
-
if (
|
|
217
|
-
const remove = new Set(
|
|
755
|
+
if (removeBlockedBy.length) {
|
|
756
|
+
const remove = new Set(removeBlockedBy);
|
|
218
757
|
nextDeps = nextDeps.filter((id) => !remove.has(id));
|
|
219
758
|
}
|
|
220
|
-
if (
|
|
221
|
-
const dependencyError = validateDependencies(state,
|
|
759
|
+
if (addBlockedBy.length) {
|
|
760
|
+
const dependencyError = validateDependencies(state, addBlockedBy, current.id);
|
|
222
761
|
if (dependencyError)
|
|
223
762
|
return error(state, dependencyError);
|
|
224
|
-
for (const dep of
|
|
763
|
+
for (const dep of addBlockedBy) {
|
|
225
764
|
if (!nextDeps.includes(dep))
|
|
226
765
|
nextDeps.push(dep);
|
|
227
766
|
}
|
|
228
767
|
}
|
|
229
|
-
|
|
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)) {
|
|
230
793
|
return error(state, "blockedBy would create a cycle");
|
|
231
794
|
}
|
|
795
|
+
// Readiness gates explicit status changes. An already active task that
|
|
796
|
+
// takes on a new incomplete edge is instead automatically demoted below.
|
|
232
797
|
if ((nextStatus === "in_progress" || nextStatus === "completed") && current.status !== nextStatus) {
|
|
233
798
|
const readyError = validateDependenciesReady(state, nextDeps);
|
|
234
799
|
if (readyError)
|
|
@@ -237,69 +802,131 @@ export function applyTodoMutation(input, params) {
|
|
|
237
802
|
const updated = { ...current, status: nextStatus };
|
|
238
803
|
if (params.subject !== undefined)
|
|
239
804
|
updated.subject = params.subject.trim();
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
+
}
|
|
246
824
|
if (nextDeps.length)
|
|
247
825
|
updated.blockedBy = nextDeps;
|
|
248
826
|
else
|
|
249
827
|
delete updated.blockedBy;
|
|
250
828
|
if (params.metadata !== undefined) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (value === null)
|
|
254
|
-
delete merged[key];
|
|
255
|
-
else
|
|
256
|
-
merged[key] = value;
|
|
257
|
-
}
|
|
258
|
-
if (Object.keys(merged).length)
|
|
259
|
-
updated.metadata = merged;
|
|
829
|
+
if (metadata)
|
|
830
|
+
updated.metadata = metadata;
|
|
260
831
|
else
|
|
261
832
|
delete updated.metadata;
|
|
262
833
|
}
|
|
263
|
-
const items =
|
|
264
|
-
|
|
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
|
+
}
|
|
265
863
|
// Exactly one in_progress: demote any other active tasks to pending.
|
|
266
864
|
const demotedIds = [];
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (item.status !== "in_progress")
|
|
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")
|
|
273
870
|
continue;
|
|
274
|
-
items[
|
|
871
|
+
items[itemIndex] = { ...item, status: "pending" };
|
|
275
872
|
demotedIds.push(item.id);
|
|
276
873
|
}
|
|
277
874
|
}
|
|
278
875
|
const nextState = { items, nextId: state.nextId };
|
|
279
|
-
const
|
|
876
|
+
const finalItem = items[index];
|
|
877
|
+
const newlyCompleted = current.status !== "completed" && finalItem.status === "completed";
|
|
280
878
|
return {
|
|
281
879
|
state: nextState,
|
|
282
880
|
operation: {
|
|
283
881
|
kind: "update",
|
|
284
|
-
id:
|
|
882
|
+
id: finalItem.id,
|
|
285
883
|
from: current.status,
|
|
286
|
-
to:
|
|
884
|
+
to: finalItem.status,
|
|
287
885
|
...(demotedIds.length ? { demotedIds } : {}),
|
|
886
|
+
...(blockedIds.length ? { blockedIds } : {}),
|
|
288
887
|
...(newlyCompleted && needsVerificationNudge(nextState) ? { verificationNudge: true } : {}),
|
|
289
888
|
},
|
|
290
889
|
};
|
|
291
890
|
}
|
|
292
|
-
case "list":
|
|
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
|
+
}
|
|
293
911
|
return {
|
|
294
912
|
state,
|
|
295
913
|
operation: {
|
|
296
914
|
kind: "list",
|
|
297
915
|
status: params.status,
|
|
298
916
|
includeDeleted: params.includeDeleted === true,
|
|
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,
|
|
299
925
|
},
|
|
300
926
|
};
|
|
927
|
+
}
|
|
301
928
|
case "get": {
|
|
302
|
-
if (params.id
|
|
929
|
+
if (!isPositiveInteger(params.id))
|
|
303
930
|
return error(state, "id required for get");
|
|
304
931
|
const item = findItem(state, params.id);
|
|
305
932
|
if (!item)
|
|
@@ -307,7 +934,7 @@ export function applyTodoMutation(input, params) {
|
|
|
307
934
|
return { state, operation: { kind: "get", item } };
|
|
308
935
|
}
|
|
309
936
|
case "delete": {
|
|
310
|
-
if (params.id
|
|
937
|
+
if (!isPositiveInteger(params.id))
|
|
311
938
|
return error(state, "id required for delete");
|
|
312
939
|
const index = state.items.findIndex((item) => item.id === params.id);
|
|
313
940
|
if (index === -1)
|
|
@@ -315,12 +942,23 @@ export function applyTodoMutation(input, params) {
|
|
|
315
942
|
const current = state.items[index];
|
|
316
943
|
if (current.status === "deleted")
|
|
317
944
|
return error(state, `#${params.id} is already deleted`);
|
|
945
|
+
const deleted = markDeleted(state, current.id);
|
|
318
946
|
return {
|
|
319
|
-
state:
|
|
320
|
-
operation: {
|
|
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
|
+
},
|
|
321
954
|
};
|
|
322
955
|
}
|
|
323
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
|
+
}
|
|
324
962
|
return {
|
|
325
963
|
// IDs remain monotonic across a clear so stale references cannot point
|
|
326
964
|
// at an unrelated task created later on the same conversation branch.
|
|
@@ -328,71 +966,297 @@ export function applyTodoMutation(input, params) {
|
|
|
328
966
|
operation: { kind: "clear", count: state.items.length },
|
|
329
967
|
};
|
|
330
968
|
}
|
|
331
|
-
return error(state, `unknown action: ${params.action}`);
|
|
332
969
|
}
|
|
333
|
-
function
|
|
334
|
-
if (!value
|
|
970
|
+
function isTodoStatusCounts(value) {
|
|
971
|
+
if (!isPlainRecord(value))
|
|
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;
|
|
978
|
+
}
|
|
979
|
+
function isOperationSummary(value, action) {
|
|
980
|
+
if (!isPlainRecord(value) || value.kind !== action)
|
|
335
981
|
return false;
|
|
336
|
-
|
|
337
|
-
|
|
982
|
+
switch (action) {
|
|
983
|
+
case "create":
|
|
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
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function normalizeSnapshotState(itemsValue, nextIdValue) {
|
|
1009
|
+
if (!Array.isArray(itemsValue) || !isPositiveInteger(nextIdValue))
|
|
1010
|
+
return undefined;
|
|
1011
|
+
const items = [];
|
|
1012
|
+
const ids = new Set();
|
|
1013
|
+
const itemsById = new Map();
|
|
1014
|
+
for (const rawItem of itemsValue) {
|
|
1015
|
+
if (!isPlainRecord(rawItem) || !isPositiveInteger(rawItem.id) || ids.has(rawItem.id))
|
|
1016
|
+
return undefined;
|
|
1017
|
+
if (typeof rawItem.subject !== "string" || !isTodoStatus(rawItem.status))
|
|
1018
|
+
return undefined;
|
|
1019
|
+
if (rawItem.description !== undefined && typeof rawItem.description !== "string")
|
|
1020
|
+
return undefined;
|
|
1021
|
+
if (rawItem.activeForm !== undefined && typeof rawItem.activeForm !== "string")
|
|
1022
|
+
return undefined;
|
|
1023
|
+
if (rawItem.owner !== undefined && typeof rawItem.owner !== "string")
|
|
1024
|
+
return undefined;
|
|
1025
|
+
let blockedBy;
|
|
1026
|
+
if (rawItem.blockedBy !== undefined) {
|
|
1027
|
+
if (!Array.isArray(rawItem.blockedBy) || !rawItem.blockedBy.every(isPositiveInteger))
|
|
1028
|
+
return undefined;
|
|
1029
|
+
blockedBy = Array.from(new Set(rawItem.blockedBy));
|
|
1030
|
+
}
|
|
1031
|
+
let metadata;
|
|
1032
|
+
if (rawItem.metadata !== undefined) {
|
|
1033
|
+
const metadataResult = validateAndCloneMetadata(rawItem.metadata, LEGACY_METADATA_LIMITS);
|
|
1034
|
+
if ("error" in metadataResult)
|
|
1035
|
+
return undefined;
|
|
1036
|
+
metadata = metadataResult.metadata;
|
|
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()))
|
|
1064
|
+
return undefined;
|
|
1065
|
+
// Legacy snapshots could contain the formerly permitted "active but newly
|
|
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)
|
|
1076
|
+
return undefined;
|
|
1077
|
+
items.sort((first, second) => first.id - second.id);
|
|
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);
|
|
338
1089
|
}
|
|
339
1090
|
export function replayTodosFromBranch(ctx) {
|
|
340
|
-
// Latest todo toolResult wins; scan tail → head so
|
|
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.
|
|
341
1093
|
const branch = Array.from(ctx.sessionManager.getBranch());
|
|
342
|
-
for (let
|
|
343
|
-
const
|
|
344
|
-
if (
|
|
1094
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
1095
|
+
const entry = branch[index];
|
|
1096
|
+
if (entry.type !== "message")
|
|
345
1097
|
continue;
|
|
346
|
-
if (
|
|
1098
|
+
if (entry.message?.role !== "toolResult" || entry.message.toolName !== TODO_TOOL_NAME)
|
|
347
1099
|
continue;
|
|
348
|
-
|
|
1100
|
+
let state;
|
|
1101
|
+
try {
|
|
1102
|
+
state = normalizeDetails(entry.message.details);
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
// Session history is external input; ignore hostile or malformed details.
|
|
349
1106
|
continue;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
});
|
|
1107
|
+
}
|
|
1108
|
+
if (state)
|
|
1109
|
+
return cloneState(state);
|
|
354
1110
|
}
|
|
355
1111
|
return cloneState(EMPTY_TODO_STATE);
|
|
356
1112
|
}
|
|
357
|
-
|
|
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);
|
|
358
1192
|
return {
|
|
1193
|
+
schemaVersion: TODO_DETAILS_SCHEMA_VERSION,
|
|
359
1194
|
action: params.action,
|
|
360
|
-
params: params,
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1195
|
+
params: cloneDetailsParams(params),
|
|
1196
|
+
operation: buildTodoOperationSummary(params, snapshot),
|
|
1197
|
+
items: snapshot.items,
|
|
1198
|
+
nextId: snapshot.nextId,
|
|
364
1199
|
};
|
|
365
1200
|
}
|
|
366
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
|
+
}
|
|
367
1230
|
export function formatTodoContent(operation, state) {
|
|
368
1231
|
switch (operation.kind) {
|
|
369
1232
|
case "create": {
|
|
370
1233
|
const item = findItem(state, operation.id);
|
|
371
|
-
return item
|
|
1234
|
+
return item
|
|
1235
|
+
? `Created #${item.id}: ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)} (pending)`
|
|
1236
|
+
: `Created #${operation.id}`;
|
|
372
1237
|
}
|
|
1238
|
+
case "create_many":
|
|
1239
|
+
return `Created ${operation.ids.length} tasks: ${operation.ids.map((id) => `#${id}`).join(", ")}`;
|
|
373
1240
|
case "update": {
|
|
374
1241
|
const transition = operation.from === operation.to ? "" : ` (${operation.from} -> ${operation.to})`;
|
|
375
1242
|
let text = `Updated #${operation.id}${transition}`;
|
|
376
1243
|
if (operation.demotedIds?.length) {
|
|
377
1244
|
text += `; ${operation.demotedIds.map((id) => `#${id}`).join(",")} demoted to pending`;
|
|
378
1245
|
}
|
|
1246
|
+
if (operation.blockedIds?.length) {
|
|
1247
|
+
text += `; ${operation.blockedIds.map((id) => `#${id}`).join(",")} moved to pending (dependencies incomplete)`;
|
|
1248
|
+
}
|
|
1249
|
+
text += formatReleasedSuffix(operation.releasedIds);
|
|
379
1250
|
if (operation.verificationNudge)
|
|
380
1251
|
text += `\n\n${VERIFICATION_NUDGE_NOTE}`;
|
|
381
1252
|
return text;
|
|
382
1253
|
}
|
|
383
1254
|
case "delete":
|
|
384
|
-
return `Deleted #${operation.id}: ${operation.subject}`;
|
|
1255
|
+
return `Deleted #${operation.id}: ${formatDisplayText(operation.subject, TODO_MAX_SUBJECT_LENGTH)}${formatReleasedSuffix(operation.releasedIds)}`;
|
|
385
1256
|
case "clear":
|
|
386
1257
|
return `Cleared ${operation.count} tasks`;
|
|
387
|
-
case "list":
|
|
388
|
-
|
|
389
|
-
// An explicit deleted-status query is sufficient intent to include tombstones.
|
|
390
|
-
if (!operation.includeDeleted && operation.status !== "deleted")
|
|
391
|
-
view = view.filter((item) => item.status !== "deleted");
|
|
392
|
-
if (operation.status)
|
|
393
|
-
view = view.filter((item) => item.status === operation.status);
|
|
394
|
-
return formatBoundedList(view, operation.status, operation.includeDeleted);
|
|
395
|
-
}
|
|
1258
|
+
case "list":
|
|
1259
|
+
return formatBoundedList(getListView(operation, state), state, operation);
|
|
396
1260
|
case "get":
|
|
397
1261
|
return formatDetailItem(operation.item, state);
|
|
398
1262
|
case "error":
|
|
@@ -415,38 +1279,56 @@ function needsVerificationNudge(state) {
|
|
|
415
1279
|
return !completed.some((item) => VERIFICATION_PATTERN.test(item.subject) ||
|
|
416
1280
|
(item.description !== undefined && VERIFICATION_PATTERN.test(item.description)));
|
|
417
1281
|
}
|
|
418
|
-
function formatBoundedList(view,
|
|
1282
|
+
function formatBoundedList(view, state, operation) {
|
|
419
1283
|
if (!view.length)
|
|
420
1284
|
return "No tasks";
|
|
421
|
-
const shown = view.slice(0,
|
|
422
|
-
const lines = shown.map(formatListItem);
|
|
1285
|
+
const shown = view.slice(0, operation.limit);
|
|
1286
|
+
const lines = shown.map((item) => formatListItem(item, state));
|
|
423
1287
|
const omitted = view.length - shown.length;
|
|
424
1288
|
if (omitted > 0) {
|
|
425
|
-
|
|
426
|
-
|
|
1289
|
+
if (!operation.legacyOutput) {
|
|
1290
|
+
lines.push(`… and ${omitted} more; next page: afterId=${shown.at(-1)?.id}.`);
|
|
1291
|
+
}
|
|
1292
|
+
else {
|
|
1293
|
+
// Do not suggest a filter that is already applied.
|
|
1294
|
+
const hint = operation.status ? "use get with id= for details" : "narrow with status= or use get with id=";
|
|
1295
|
+
lines.push(`… and ${omitted} more (${shown.length} of ${view.length} shown); ${hint}.`);
|
|
1296
|
+
}
|
|
427
1297
|
}
|
|
428
1298
|
return lines.join("\n");
|
|
429
1299
|
}
|
|
430
|
-
function
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
1300
|
+
function formatListIdList(ids) {
|
|
1301
|
+
const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
|
|
1302
|
+
return ids.length > shown.length ? `${shown.join(",")},…+${ids.length - shown.length}` : shown.join(",");
|
|
1303
|
+
}
|
|
1304
|
+
function formatListItem(item, state) {
|
|
1305
|
+
const unresolved = unresolvedDependencyIds(state, item);
|
|
1306
|
+
const deps = unresolved.length ? ` blockedBy=${formatListIdList(unresolved)}` : "";
|
|
1307
|
+
const owner = item.owner ? ` @${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}` : "";
|
|
1308
|
+
const active = item.status === "in_progress" && item.activeForm
|
|
1309
|
+
? ` (${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)})`
|
|
1310
|
+
: "";
|
|
1311
|
+
return `[${item.status}] #${item.id} ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}${active}${owner}${deps}`;
|
|
1312
|
+
}
|
|
1313
|
+
function formatIdList(ids) {
|
|
1314
|
+
const shown = ids.slice(0, TODO_MAX_BLOCKED_BY).map((id) => `#${id}`);
|
|
1315
|
+
return ids.length > shown.length ? `${shown.join(", ")}, … +${ids.length - shown.length}` : shown.join(", ");
|
|
434
1316
|
}
|
|
435
1317
|
function formatDetailItem(item, state) {
|
|
436
|
-
const lines = [`#${item.id} [${item.status}] ${item.subject}`];
|
|
1318
|
+
const lines = [`#${item.id} [${item.status}] ${formatDisplayText(item.subject, TODO_MAX_SUBJECT_LENGTH)}`];
|
|
437
1319
|
if (item.description)
|
|
438
|
-
lines.push(`description: ${item.description}`);
|
|
1320
|
+
lines.push(`description: ${formatDisplayText(item.description, TODO_MAX_DESCRIPTION_LENGTH)}`);
|
|
439
1321
|
if (item.activeForm)
|
|
440
|
-
lines.push(`activeForm: ${item.activeForm}`);
|
|
1322
|
+
lines.push(`activeForm: ${formatDisplayText(item.activeForm, TODO_MAX_ACTIVE_FORM_LENGTH)}`);
|
|
441
1323
|
if (item.blockedBy?.length)
|
|
442
|
-
lines.push(`blockedBy: ${item.blockedBy
|
|
1324
|
+
lines.push(`blockedBy: ${formatIdList(item.blockedBy)}`);
|
|
443
1325
|
const blocks = state.items
|
|
444
1326
|
.filter((candidate) => candidate.blockedBy?.includes(item.id))
|
|
445
1327
|
.map((candidate) => candidate.id);
|
|
446
1328
|
if (blocks.length)
|
|
447
|
-
lines.push(`blocks: ${blocks
|
|
1329
|
+
lines.push(`blocks: ${formatIdList(blocks)}`);
|
|
448
1330
|
if (item.owner)
|
|
449
|
-
lines.push(`owner: ${item.owner}`);
|
|
1331
|
+
lines.push(`owner: ${formatDisplayText(item.owner, TODO_MAX_OWNER_LENGTH)}`);
|
|
450
1332
|
return lines.join("\n");
|
|
451
1333
|
}
|
|
452
1334
|
//# sourceMappingURL=state.js.map
|