@asterxsk/kiln 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -155
- package/agent/AGENTS.md +67 -67
- package/agent/README.md +5 -5
- package/agent/extensions/ask-user/index.ts +418 -418
- package/agent/extensions/ask-user/install.ps1 +27 -27
- package/agent/extensions/ask-user/install.sh +24 -24
- package/agent/extensions/ask-user/package-lock.json +769 -769
- package/agent/extensions/ask-user/package.json +19 -19
- package/agent/extensions/ask-user/prompt.ts +45 -45
- package/agent/extensions/ask-user/tsconfig.json +7 -7
- package/agent/extensions/background-terminals/docs/implementation-guide.md +942 -942
- package/agent/extensions/background-terminals/index.ts +627 -627
- package/agent/extensions/background-terminals/install.ps1 +27 -27
- package/agent/extensions/background-terminals/install.sh +24 -24
- package/agent/extensions/background-terminals/manager.test.ts +735 -735
- package/agent/extensions/background-terminals/output.test.ts +109 -109
- package/agent/extensions/background-terminals/package-lock.json +769 -769
- package/agent/extensions/background-terminals/package.json +17 -17
- package/agent/extensions/background-terminals/prompt.test.ts +125 -125
- package/agent/extensions/background-terminals/ps.test.ts +82 -82
- package/agent/extensions/background-terminals/result-delivery.test.ts +44 -44
- package/agent/extensions/background-terminals/src/domain.ts +87 -87
- package/agent/extensions/background-terminals/src/manager.ts +907 -907
- package/agent/extensions/background-terminals/src/output.ts +84 -84
- package/agent/extensions/background-terminals/src/prompt.ts +142 -142
- package/agent/extensions/background-terminals/src/result-delivery.ts +27 -27
- package/agent/extensions/background-terminals/src/runtime.ts +36 -36
- package/agent/extensions/background-terminals/src/ui/output-view.ts +79 -79
- package/agent/extensions/background-terminals/src/ui/ps.ts +621 -621
- package/agent/extensions/background-terminals/tsconfig.json +7 -7
- package/agent/extensions/destructive/README.md +31 -31
- package/agent/extensions/destructive/index.ts +88 -88
- package/agent/extensions/destructive/install.ps1 +27 -27
- package/agent/extensions/destructive/install.sh +24 -24
- package/agent/extensions/file-search/index.spec.ts +443 -443
- package/agent/extensions/file-search/index.ts +459 -459
- package/agent/extensions/file-search/install.ps1 +27 -27
- package/agent/extensions/file-search/install.sh +24 -24
- package/agent/extensions/file-search/package-lock.json +2253 -2253
- package/agent/extensions/file-search/package.json +23 -23
- package/agent/extensions/file-search/src/args.ts +122 -122
- package/agent/extensions/file-search/src/binaries.ts +422 -422
- package/agent/extensions/file-search/src/output.ts +126 -126
- package/agent/extensions/file-search/src/process.ts +146 -146
- package/agent/extensions/file-search/src/prompt.ts +52 -52
- package/agent/extensions/file-search/tsconfig.json +7 -7
- package/agent/extensions/goal/README.md +50 -50
- package/agent/extensions/goal/index.ts +155 -155
- package/agent/extensions/goal/install.ps1 +27 -27
- package/agent/extensions/goal/install.sh +24 -24
- package/agent/extensions/kiln-update/README.md +38 -25
- package/agent/extensions/kiln-update/index.ts +177 -99
- package/agent/extensions/modelconf/PLAN.md +915 -915
- package/agent/extensions/modelconf/README.md +66 -66
- package/agent/extensions/modelconf/index.ts +296 -296
- package/agent/extensions/modelconf/install.ps1 +27 -27
- package/agent/extensions/modelconf/install.sh +24 -24
- package/agent/extensions/modelconf/package-lock.json +1809 -1809
- package/agent/extensions/modelconf/package.json +13 -13
- package/agent/extensions/modelconf/src/fuzzy.ts +26 -26
- package/agent/extensions/modelconf/src/glob.ts +13 -13
- package/agent/extensions/modelconf/src/persistence.test.ts +46 -46
- package/agent/extensions/modelconf/src/persistence.ts +164 -164
- package/agent/extensions/modelconf/src/ui/ModelConfView.test.ts +75 -75
- package/agent/extensions/modelconf/src/ui/ModelConfView.ts +1101 -1101
- package/agent/extensions/modelconf/tsconfig.json +15 -15
- package/agent/extensions/pi-web-access/CHANGELOG.md +690 -690
- package/agent/extensions/pi-web-access/LICENSE +21 -21
- package/agent/extensions/pi-web-access/README.md +470 -470
- package/agent/extensions/pi-web-access/SECURITY.md +5 -5
- package/agent/extensions/pi-web-access/activity.ts +101 -101
- package/agent/extensions/pi-web-access/auth-fetch.ts +148 -148
- package/agent/extensions/pi-web-access/brightdata-unlocker.ts +272 -272
- package/agent/extensions/pi-web-access/chrome-cookies.ts +669 -669
- package/agent/extensions/pi-web-access/content-find.ts +139 -139
- package/agent/extensions/pi-web-access/credential-source.ts +191 -191
- package/agent/extensions/pi-web-access/data-uri-sanitize.ts +406 -406
- package/agent/extensions/pi-web-access/datalab-pdf-extract.ts +568 -568
- package/agent/extensions/pi-web-access/declared-web-links.ts +173 -173
- package/agent/extensions/pi-web-access/evidence/CONTRACT-EVIDENCE.md +496 -496
- package/agent/extensions/pi-web-access/evidence/contract-probe.mjs +140 -140
- package/agent/extensions/pi-web-access/exa.ts +526 -526
- package/agent/extensions/pi-web-access/extract.ts +1196 -1196
- package/agent/extensions/pi-web-access/feature-config.ts +29 -29
- package/agent/extensions/pi-web-access/fetch-params.ts +111 -111
- package/agent/extensions/pi-web-access/gemini-adc.ts +298 -298
- package/agent/extensions/pi-web-access/gemini-api.ts +353 -353
- package/agent/extensions/pi-web-access/gemini-pdf-extract.ts +108 -108
- package/agent/extensions/pi-web-access/gemini-search.ts +21 -21
- package/agent/extensions/pi-web-access/gemini-url-context.ts +128 -128
- package/agent/extensions/pi-web-access/gemini-web-config.ts +101 -101
- package/agent/extensions/pi-web-access/gemini-web.ts +487 -487
- package/agent/extensions/pi-web-access/github-api.ts +197 -197
- package/agent/extensions/pi-web-access/github-extract.ts +746 -746
- package/agent/extensions/pi-web-access/github-issue-pr.ts +700 -700
- package/agent/extensions/pi-web-access/index.ts +1737 -1737
- package/agent/extensions/pi-web-access/package-lock.json +5808 -5808
- package/agent/extensions/pi-web-access/package.json +64 -64
- package/agent/extensions/pi-web-access/page-query.ts +96 -96
- package/agent/extensions/pi-web-access/pdf-extract.ts +409 -409
- package/agent/extensions/pi-web-access/promise-try.d.ts +7 -7
- package/agent/extensions/pi-web-access/query-rewrite.ts +51 -51
- package/agent/extensions/pi-web-access/render-search-error.ts +170 -170
- package/agent/extensions/pi-web-access/rsc-extract.ts +338 -338
- package/agent/extensions/pi-web-access/search-types.ts +20 -20
- package/agent/extensions/pi-web-access/source-check.ts +282 -282
- package/agent/extensions/pi-web-access/ssrf-protection.ts +526 -526
- package/agent/extensions/pi-web-access/storage.ts +521 -521
- package/agent/extensions/pi-web-access/summary-model-scope.ts +125 -125
- package/agent/extensions/pi-web-access/test/auth-fetch.test.mjs +208 -208
- package/agent/extensions/pi-web-access/test/brightdata-unlocker.test.mjs +840 -840
- package/agent/extensions/pi-web-access/test/chrome-cookie-extraction.test.mjs +441 -441
- package/agent/extensions/pi-web-access/test/config-path.test.mjs +283 -283
- package/agent/extensions/pi-web-access/test/content-find.test.mjs +25 -25
- package/agent/extensions/pi-web-access/test/credential-source.test.mjs +118 -118
- package/agent/extensions/pi-web-access/test/data-uri-sanitize.test.mjs +210 -210
- package/agent/extensions/pi-web-access/test/datalab-pdf-extract.test.mjs +552 -552
- package/agent/extensions/pi-web-access/test/declared-web-links.test.mjs +212 -212
- package/agent/extensions/pi-web-access/test/fetch-answer-storage.test.mjs +40 -40
- package/agent/extensions/pi-web-access/test/fetch-cache-storage.test.mjs +334 -334
- package/agent/extensions/pi-web-access/test/fetch-content-domain-policy.test.mjs +95 -95
- package/agent/extensions/pi-web-access/test/fetch-modes.test.mjs +53 -53
- package/agent/extensions/pi-web-access/test/fetch-not-found-guidance.test.mjs +92 -92
- package/agent/extensions/pi-web-access/test/fetch-params.test.mjs +86 -86
- package/agent/extensions/pi-web-access/test/fetch-render-call.test.mjs +34 -34
- package/agent/extensions/pi-web-access/test/fetch-routing.test.mjs +173 -173
- package/agent/extensions/pi-web-access/test/gemini-adc-auth.test.mjs +257 -257
- package/agent/extensions/pi-web-access/test/gemini-api-transport.test.mjs +170 -170
- package/agent/extensions/pi-web-access/test/gemini-pdf-extract.test.mjs +133 -133
- package/agent/extensions/pi-web-access/test/gemini-web-cookie-opt-in.test.mjs +178 -178
- package/agent/extensions/pi-web-access/test/gemini-web-header-overflow.test.mjs +148 -148
- package/agent/extensions/pi-web-access/test/get-search-content.test.mjs +223 -223
- package/agent/extensions/pi-web-access/test/github-extract.test.mjs +378 -378
- package/agent/extensions/pi-web-access/test/github-issue-pr.test.mjs +565 -565
- package/agent/extensions/pi-web-access/test/inline-content-config.test.mjs +99 -99
- package/agent/extensions/pi-web-access/test/lazy-extract-load.test.mjs +118 -118
- package/agent/extensions/pi-web-access/test/local-video-oversize.test.mjs +52 -52
- package/agent/extensions/pi-web-access/test/package-typebox-dependency.test.mjs +50 -50
- package/agent/extensions/pi-web-access/test/page-query.test.mjs +51 -51
- package/agent/extensions/pi-web-access/test/pdf-config.test.mjs +140 -140
- package/agent/extensions/pi-web-access/test/pdf-extract.test.mjs +500 -500
- package/agent/extensions/pi-web-access/test/proxy-transport.test.mjs +286 -286
- package/agent/extensions/pi-web-access/test/query-rewrite.test.mjs +52 -52
- package/agent/extensions/pi-web-access/test/rsc-fallback.test.mjs +102 -102
- package/agent/extensions/pi-web-access/test/search-error-render.test.mjs +152 -152
- package/agent/extensions/pi-web-access/test/search-providers.test.mjs +274 -274
- package/agent/extensions/pi-web-access/test/source-check.test.mjs +179 -179
- package/agent/extensions/pi-web-access/test/ssrf-allow-ranges-config.test.mjs +205 -205
- package/agent/extensions/pi-web-access/test/ssrf-protection.test.mjs +456 -456
- package/agent/extensions/pi-web-access/test/summary-model-scope.test.mjs +106 -106
- package/agent/extensions/pi-web-access/test/tool-registration-config.test.mjs +182 -182
- package/agent/extensions/pi-web-access/test/web-search-answer-render.test.mjs +66 -66
- package/agent/extensions/pi-web-access/test/youtube-extract-errors.test.mjs +64 -64
- package/agent/extensions/pi-web-access/tsconfig.json +11 -11
- package/agent/extensions/pi-web-access/utils.ts +451 -451
- package/agent/extensions/pi-web-access/video-extract.ts +392 -392
- package/agent/extensions/pi-web-access/youtube-extract.ts +328 -328
- package/agent/extensions/shared/activity-status.ts +31 -31
- package/agent/extensions/shared/child-session.test.ts +270 -270
- package/agent/extensions/shared/child-session.ts +148 -148
- package/agent/extensions/shared/context-utilization.test.ts +48 -48
- package/agent/extensions/shared/context-utilization.ts +47 -47
- package/agent/extensions/shared/dashboard-state.ts +99 -99
- package/agent/extensions/shared/install.ps1 +27 -27
- package/agent/extensions/shared/install.sh +24 -24
- package/agent/extensions/shared/tool-call-timeout.test.ts +117 -117
- package/agent/extensions/shared/tool-call-timeout.ts +104 -104
- package/agent/extensions/skillsconf/README.md +74 -74
- package/agent/extensions/skillsconf/index.ts +92 -92
- package/agent/extensions/skillsconf/install.ps1 +27 -27
- package/agent/extensions/skillsconf/install.sh +24 -24
- package/agent/extensions/skillsconf/package-lock.json +1809 -1809
- package/agent/extensions/skillsconf/package.json +18 -18
- package/agent/extensions/skillsconf/src/delete-skill.test.ts +64 -64
- package/agent/extensions/skillsconf/src/delete-skill.ts +45 -45
- package/agent/extensions/skillsconf/src/filter.test.ts +80 -80
- package/agent/extensions/skillsconf/src/filter.ts +74 -74
- package/agent/extensions/skillsconf/src/fuzzy.ts +26 -26
- package/agent/extensions/skillsconf/src/persistence.test.ts +72 -72
- package/agent/extensions/skillsconf/src/persistence.ts +169 -169
- package/agent/extensions/skillsconf/src/ui/SkillConfView.test.ts +337 -337
- package/agent/extensions/skillsconf/src/ui/SkillConfView.ts +758 -758
- package/agent/extensions/skillsconf/src/ui/text-input.ts +91 -91
- package/agent/extensions/skillsconf/src/ui/tui-helpers.ts +144 -144
- package/agent/extensions/skillsconf/tsconfig.json +15 -15
- package/agent/extensions/statusline/index.ts +273 -282
- package/agent/extensions/statusline/install.ps1 +27 -27
- package/agent/extensions/statusline/install.sh +24 -24
- package/agent/extensions/subagents/by-the-way.test.ts +29 -29
- package/agent/extensions/subagents/claude.test.ts +119 -119
- package/agent/extensions/subagents/codex.test.ts +102 -102
- package/agent/extensions/subagents/context-usage.test.ts +107 -107
- package/agent/extensions/subagents/docs/design-plan.md +568 -568
- package/agent/extensions/subagents/docs/effect-v4-extension-guide.md +354 -354
- package/agent/extensions/subagents/docs/effect-v4-notes.md +571 -571
- package/agent/extensions/subagents/index.ts +779 -779
- package/agent/extensions/subagents/install.ps1 +27 -27
- package/agent/extensions/subagents/install.sh +24 -24
- package/agent/extensions/subagents/manager.test.ts +276 -276
- package/agent/extensions/subagents/package-lock.json +2244 -2244
- package/agent/extensions/subagents/package.json +19 -19
- package/agent/extensions/subagents/result-delivery.test.ts +27 -27
- package/agent/extensions/subagents/src/backend.ts +73 -73
- package/agent/extensions/subagents/src/backends/claude.ts +701 -701
- package/agent/extensions/subagents/src/backends/codex.ts +1060 -1060
- package/agent/extensions/subagents/src/backends/pi.ts +575 -575
- package/agent/extensions/subagents/src/backends/stub.ts +300 -300
- package/agent/extensions/subagents/src/by-the-way.ts +21 -21
- package/agent/extensions/subagents/src/domain.ts +253 -253
- package/agent/extensions/subagents/src/format.ts +74 -74
- package/agent/extensions/subagents/src/manager.ts +736 -736
- package/agent/extensions/subagents/src/prompt.ts +92 -92
- package/agent/extensions/subagents/src/result-delivery.ts +20 -20
- package/agent/extensions/subagents/src/runtime.ts +53 -53
- package/agent/extensions/subagents/src/ui/takeover.ts +583 -583
- package/agent/extensions/subagents/src/ui/transcript.ts +201 -201
- package/agent/extensions/subagents/takeover.test.ts +29 -29
- package/agent/extensions/subagents/tsconfig.json +7 -7
- package/agent/extensions/todo/AGENTS.md +38 -38
- package/agent/extensions/todo/LICENSE +21 -21
- package/agent/extensions/todo/config.ts +55 -55
- package/agent/extensions/todo/index.ts +151 -151
- package/agent/extensions/todo/install.ps1 +27 -27
- package/agent/extensions/todo/install.sh +24 -24
- package/agent/extensions/todo/locales/de.json +17 -17
- package/agent/extensions/todo/locales/en.json +15 -15
- package/agent/extensions/todo/locales/es.json +17 -17
- package/agent/extensions/todo/locales/fr.json +17 -17
- package/agent/extensions/todo/locales/pt-BR.json +17 -17
- package/agent/extensions/todo/locales/pt.json +17 -17
- package/agent/extensions/todo/locales/ru.json +17 -17
- package/agent/extensions/todo/locales/uk.json +17 -17
- package/agent/extensions/todo/locales/zh.json +17 -17
- package/agent/extensions/todo/package-lock.json +3358 -3358
- package/agent/extensions/todo/package.json +67 -67
- package/agent/extensions/todo/state/i18n-bridge.ts +64 -64
- package/agent/extensions/todo/state/invariants.ts +20 -20
- package/agent/extensions/todo/state/replay.ts +38 -38
- package/agent/extensions/todo/state/selectors.ts +107 -107
- package/agent/extensions/todo/state/state-reducer.ts +326 -326
- package/agent/extensions/todo/state/state.ts +18 -18
- package/agent/extensions/todo/state/store.ts +82 -82
- package/agent/extensions/todo/state/task-graph.ts +57 -57
- package/agent/extensions/todo/todo-overlay.ts +200 -200
- package/agent/extensions/todo/todo.ts +155 -155
- package/agent/extensions/todo/tool/response-envelope.ts +109 -109
- package/agent/extensions/todo/tool/types.ts +206 -206
- package/agent/extensions/todo/view/format.ts +177 -177
- package/agent/extensions/trim-context/README.md +54 -54
- package/agent/extensions/trim-context/index.ts +487 -487
- package/agent/extensions/trim-context/install.ps1 +27 -27
- package/agent/extensions/trim-context/install.sh +24 -24
- package/agent/keybindings.json +7 -7
- package/agent/version.txt +1 -1
- package/bin/kiln.js +20 -13
- package/package.json +1 -1
- package/agent/extensions/taste/index.ts +0 -443
- package/agent/extensions/taste/install.ps1 +0 -27
- package/agent/extensions/taste/install.sh +0 -24
|
@@ -1,326 +1,326 @@
|
|
|
1
|
-
import type { Task, TaskAction, TaskMutationParams, TaskStatus } from "../tool/types.js";
|
|
2
|
-
import { isTransitionValid } from "./invariants.js";
|
|
3
|
-
import type { TaskState } from "./state.js";
|
|
4
|
-
import { detectCycle } from "./task-graph.js";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Reducer outcome. Closed tagged union — adding a new action requires extending
|
|
8
|
-
* this union AND the response-envelope's `formatContent` switch (compiler-
|
|
9
|
-
* enforced exhaustive). Mirrors the `Effect` pattern in
|
|
10
|
-
* `packages/rpiv-ask-user-question/state/state-reducer.ts:14-30`.
|
|
11
|
-
*
|
|
12
|
-
* `error` carries the message in-band so callers can pattern-match on
|
|
13
|
-
* `op.kind === "error"` without a side-channel boolean.
|
|
14
|
-
*/
|
|
15
|
-
export type Op =
|
|
16
|
-
| { kind: "create"; ids: number[] }
|
|
17
|
-
| { kind: "update"; results: Array<{ id: number; fromStatus: TaskStatus; toStatus: TaskStatus }> }
|
|
18
|
-
| { kind: "delete"; items: Array<{ id: number; subject: string }> }
|
|
19
|
-
| { kind: "list"; statusFilter?: TaskStatus; includeDeleted: boolean }
|
|
20
|
-
| { kind: "get"; tasks: Task[] }
|
|
21
|
-
| { kind: "clear"; count: number }
|
|
22
|
-
| { kind: "batch"; results: Array<{ index: number; op: Exclude<Op, { kind: "batch" }> }> }
|
|
23
|
-
| { kind: "error"; message: string };
|
|
24
|
-
|
|
25
|
-
export interface ApplyResult {
|
|
26
|
-
state: TaskState;
|
|
27
|
-
op: Op;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function errorResult(state: TaskState, message: string): ApplyResult {
|
|
31
|
-
return { state, op: { kind: "error", message } };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Pure reducer: (state, action, params) → (state, op). Mirrors the
|
|
36
|
-
* `applyTaskMutation` of pre-refactor `todo.ts` minus content/details
|
|
37
|
-
* formatting; the response envelope (`tool/response-envelope.ts`) owns
|
|
38
|
-
* formatting, the store (`state/store.ts`) owns commit.
|
|
39
|
-
*
|
|
40
|
-
* Validation is in-line: structural guards (`subject required`, `id required`,
|
|
41
|
-
* `at least one mutable field`) plus state-aware checks (transition legality,
|
|
42
|
-
* dangling/deleted blockedBy, self-block, cycles). Decision: validation stays
|
|
43
|
-
* in-reducer — see Plan §Decisions §Decision 2.
|
|
44
|
-
*/
|
|
45
|
-
export function applyTaskMutation(state: TaskState, action: TaskAction, params: TaskMutationParams): ApplyResult {
|
|
46
|
-
switch (action) {
|
|
47
|
-
case "create": {
|
|
48
|
-
// Plural path: subjects[]
|
|
49
|
-
if (params.subjects?.length) {
|
|
50
|
-
const subjects = params.subjects.filter((s): s is string => typeof s === "string" && s.trim().length > 0);
|
|
51
|
-
if (subjects.length === 0) return errorResult(state, "subjects[] requires at least one non-empty subject");
|
|
52
|
-
|
|
53
|
-
const createdIds: number[] = [];
|
|
54
|
-
let cursor = state;
|
|
55
|
-
for (const subject of subjects) {
|
|
56
|
-
const subResult = applyTaskMutation(cursor, "create", {
|
|
57
|
-
...params,
|
|
58
|
-
subject,
|
|
59
|
-
subjects: undefined,
|
|
60
|
-
} as TaskMutationParams);
|
|
61
|
-
if (subResult.op.kind === "error") return subResult;
|
|
62
|
-
cursor = subResult.state;
|
|
63
|
-
if (subResult.op.kind === "create") createdIds.push(...subResult.op.ids);
|
|
64
|
-
}
|
|
65
|
-
return { state: cursor, op: { kind: "create", ids: createdIds } };
|
|
66
|
-
}
|
|
67
|
-
if (!params.subject?.trim()) {
|
|
68
|
-
return errorResult(state, "subject required for create");
|
|
69
|
-
}
|
|
70
|
-
if (params.blockedBy?.length) {
|
|
71
|
-
for (const dep of params.blockedBy) {
|
|
72
|
-
const depTask = state.tasks.find((t) => t.id === dep);
|
|
73
|
-
if (!depTask) return errorResult(state, `blockedBy: #${dep} not found`);
|
|
74
|
-
if (depTask.status === "deleted") return errorResult(state, `blockedBy: #${dep} is deleted`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
const newTask: Task = {
|
|
78
|
-
id: state.nextId,
|
|
79
|
-
subject: params.subject,
|
|
80
|
-
status: "pending",
|
|
81
|
-
};
|
|
82
|
-
if (params.description) newTask.description = params.description;
|
|
83
|
-
if (params.activeForm) newTask.activeForm = params.activeForm;
|
|
84
|
-
if (params.blockedBy?.length) newTask.blockedBy = [...params.blockedBy];
|
|
85
|
-
if (params.owner) newTask.owner = params.owner;
|
|
86
|
-
if (params.metadata) newTask.metadata = { ...params.metadata };
|
|
87
|
-
|
|
88
|
-
const newTasks = [...state.tasks, newTask];
|
|
89
|
-
return {
|
|
90
|
-
state: { tasks: newTasks, nextId: state.nextId + 1 },
|
|
91
|
-
op: { kind: "create", ids: [newTask.id] },
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
case "update": {
|
|
96
|
-
// Plural path: ids[]
|
|
97
|
-
if (params.ids?.length) {
|
|
98
|
-
const results: Array<{ id: number; fromStatus: TaskStatus; toStatus: TaskStatus }> = [];
|
|
99
|
-
let cursor = state;
|
|
100
|
-
for (const id of params.ids) {
|
|
101
|
-
const subResult = applyTaskMutation(cursor, "update", {
|
|
102
|
-
...params,
|
|
103
|
-
id,
|
|
104
|
-
ids: undefined,
|
|
105
|
-
} as TaskMutationParams);
|
|
106
|
-
if (subResult.op.kind === "error") return subResult;
|
|
107
|
-
cursor = subResult.state;
|
|
108
|
-
if (subResult.op.kind === "update") results.push(subResult.op.results[0]);
|
|
109
|
-
}
|
|
110
|
-
return { state: cursor, op: { kind: "update", results } };
|
|
111
|
-
}
|
|
112
|
-
if (params.id === undefined) return errorResult(state, "id required for update");
|
|
113
|
-
const idx = state.tasks.findIndex((t) => t.id === params.id);
|
|
114
|
-
if (idx === -1) return errorResult(state, `#${params.id} not found`);
|
|
115
|
-
const current = state.tasks[idx];
|
|
116
|
-
|
|
117
|
-
const hasMutation =
|
|
118
|
-
params.subject !== undefined ||
|
|
119
|
-
params.description !== undefined ||
|
|
120
|
-
params.activeForm !== undefined ||
|
|
121
|
-
params.status !== undefined ||
|
|
122
|
-
params.owner !== undefined ||
|
|
123
|
-
params.metadata !== undefined ||
|
|
124
|
-
(params.addBlockedBy && params.addBlockedBy.length > 0) ||
|
|
125
|
-
(params.removeBlockedBy && params.removeBlockedBy.length > 0);
|
|
126
|
-
if (!hasMutation) return errorResult(state, "update requires at least one mutable field");
|
|
127
|
-
|
|
128
|
-
let newStatus = current.status;
|
|
129
|
-
if (params.status !== undefined) {
|
|
130
|
-
if (!isTransitionValid(current.status, params.status)) {
|
|
131
|
-
return errorResult(state, `illegal transition ${current.status} → ${params.status}`);
|
|
132
|
-
}
|
|
133
|
-
newStatus = params.status;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
let newBlockedBy = current.blockedBy ? [...current.blockedBy] : [];
|
|
137
|
-
if (params.removeBlockedBy?.length) {
|
|
138
|
-
const toRemove = new Set(params.removeBlockedBy);
|
|
139
|
-
newBlockedBy = newBlockedBy.filter((dep) => !toRemove.has(dep));
|
|
140
|
-
}
|
|
141
|
-
if (params.addBlockedBy?.length) {
|
|
142
|
-
for (const dep of params.addBlockedBy) {
|
|
143
|
-
if (dep === current.id) return errorResult(state, `cannot block #${current.id} on itself`);
|
|
144
|
-
const depTask = state.tasks.find((t) => t.id === dep);
|
|
145
|
-
if (!depTask) return errorResult(state, `addBlockedBy: #${dep} not found`);
|
|
146
|
-
if (depTask.status === "deleted") return errorResult(state, `addBlockedBy: #${dep} is deleted`);
|
|
147
|
-
if (!newBlockedBy.includes(dep)) newBlockedBy.push(dep);
|
|
148
|
-
}
|
|
149
|
-
if (detectCycle(state.tasks, current.id, newBlockedBy)) {
|
|
150
|
-
return errorResult(state, "addBlockedBy would create a cycle in the blockedBy graph");
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let newMetadata = current.metadata;
|
|
155
|
-
if (params.metadata !== undefined) {
|
|
156
|
-
const merged: Record<string, unknown> = { ...(current.metadata ?? {}) };
|
|
157
|
-
for (const [k, v] of Object.entries(params.metadata)) {
|
|
158
|
-
if (v === null) delete merged[k];
|
|
159
|
-
else merged[k] = v;
|
|
160
|
-
}
|
|
161
|
-
newMetadata = Object.keys(merged).length ? merged : undefined;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const updated: Task = { ...current, status: newStatus };
|
|
165
|
-
if (params.subject !== undefined) updated.subject = params.subject;
|
|
166
|
-
if (params.description !== undefined) updated.description = params.description;
|
|
167
|
-
if (params.activeForm !== undefined) updated.activeForm = params.activeForm;
|
|
168
|
-
if (params.owner !== undefined) updated.owner = params.owner;
|
|
169
|
-
if (newBlockedBy.length) updated.blockedBy = newBlockedBy;
|
|
170
|
-
else delete updated.blockedBy;
|
|
171
|
-
if (newMetadata === undefined) delete updated.metadata;
|
|
172
|
-
else updated.metadata = newMetadata;
|
|
173
|
-
|
|
174
|
-
const newTasks = [...state.tasks];
|
|
175
|
-
newTasks[idx] = updated;
|
|
176
|
-
return {
|
|
177
|
-
state: { tasks: newTasks, nextId: state.nextId },
|
|
178
|
-
op: { kind: "update", results: [{ id: updated.id, fromStatus: current.status, toStatus: newStatus }] },
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
case "list": {
|
|
183
|
-
return {
|
|
184
|
-
state,
|
|
185
|
-
op: {
|
|
186
|
-
kind: "list",
|
|
187
|
-
includeDeleted: params.includeDeleted === true,
|
|
188
|
-
...(params.status !== undefined ? { statusFilter: params.status } : {}),
|
|
189
|
-
},
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
case "get": {
|
|
194
|
-
// Plural path: ids[]
|
|
195
|
-
if (params.ids?.length) {
|
|
196
|
-
const tasks: Task[] = [];
|
|
197
|
-
let cursor = state;
|
|
198
|
-
for (const id of params.ids) {
|
|
199
|
-
const subResult = applyTaskMutation(cursor, "get", {
|
|
200
|
-
...params,
|
|
201
|
-
id,
|
|
202
|
-
ids: undefined,
|
|
203
|
-
} as TaskMutationParams);
|
|
204
|
-
if (subResult.op.kind === "error") return subResult;
|
|
205
|
-
cursor = subResult.state;
|
|
206
|
-
if (subResult.op.kind === "get") tasks.push(subResult.op.tasks[0]);
|
|
207
|
-
}
|
|
208
|
-
return { state: cursor, op: { kind: "get", tasks } };
|
|
209
|
-
}
|
|
210
|
-
if (params.id === undefined) return errorResult(state, "id required for get");
|
|
211
|
-
const task = state.tasks.find((t) => t.id === params.id);
|
|
212
|
-
if (!task) return errorResult(state, `#${params.id} not found`);
|
|
213
|
-
return { state, op: { kind: "get", tasks: [task] } };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
case "delete": {
|
|
217
|
-
// Plural path: ids[]
|
|
218
|
-
if (params.ids?.length) {
|
|
219
|
-
const items: Array<{ id: number; subject: string }> = [];
|
|
220
|
-
let cursor = state;
|
|
221
|
-
for (const id of params.ids) {
|
|
222
|
-
const subResult = applyTaskMutation(cursor, "delete", {
|
|
223
|
-
...params,
|
|
224
|
-
id,
|
|
225
|
-
ids: undefined,
|
|
226
|
-
} as TaskMutationParams);
|
|
227
|
-
if (subResult.op.kind === "error") return subResult;
|
|
228
|
-
cursor = subResult.state;
|
|
229
|
-
if (subResult.op.kind === "delete") items.push(subResult.op.items[0]);
|
|
230
|
-
}
|
|
231
|
-
return { state: cursor, op: { kind: "delete", items } };
|
|
232
|
-
}
|
|
233
|
-
if (params.id === undefined) return errorResult(state, "id required for delete");
|
|
234
|
-
const idx = state.tasks.findIndex((t) => t.id === params.id);
|
|
235
|
-
if (idx === -1) return errorResult(state, `#${params.id} not found`);
|
|
236
|
-
const current = state.tasks[idx];
|
|
237
|
-
if (current.status === "deleted") return errorResult(state, `#${current.id} is already deleted`);
|
|
238
|
-
const updated: Task = { ...current, status: "deleted" };
|
|
239
|
-
const newTasks = [...state.tasks];
|
|
240
|
-
newTasks[idx] = updated;
|
|
241
|
-
return {
|
|
242
|
-
state: { tasks: newTasks, nextId: state.nextId },
|
|
243
|
-
op: { kind: "delete", items: [{ id: updated.id, subject: updated.subject }] },
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
case "clear": {
|
|
248
|
-
const count = state.tasks.length;
|
|
249
|
-
return {
|
|
250
|
-
state: { tasks: [], nextId: 1 },
|
|
251
|
-
op: { kind: "clear", count },
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
case "batch": {
|
|
256
|
-
if (!params.items?.length) {
|
|
257
|
-
return errorResult(state, "batch requires at least one item in items[]");
|
|
258
|
-
}
|
|
259
|
-
type BatchOp = Exclude<Op, { kind: "batch" }>;
|
|
260
|
-
const results: Array<{ index: number; op: BatchOp }> = [];
|
|
261
|
-
let currentState = state;
|
|
262
|
-
const refRegistry = new Map<string, number>();
|
|
263
|
-
|
|
264
|
-
for (let i = 0; i < params.items.length; i++) {
|
|
265
|
-
const item = params.items[i];
|
|
266
|
-
const { action: itemAction, as: itemAs, refs: itemRefs, ...itemParams } = item;
|
|
267
|
-
|
|
268
|
-
// Validate "as" labels
|
|
269
|
-
if (itemAs?.length) {
|
|
270
|
-
if (itemAction !== "create") {
|
|
271
|
-
return errorResult(state, `batch item ${i}: "as" only valid for create actions`);
|
|
272
|
-
}
|
|
273
|
-
const taskCount = item.subjects?.length ?? 1;
|
|
274
|
-
if (itemAs.length !== taskCount) {
|
|
275
|
-
return errorResult(state, `batch item ${i}: "as" (${itemAs.length}) must match task count (${taskCount})`);
|
|
276
|
-
}
|
|
277
|
-
for (const label of itemAs) {
|
|
278
|
-
if (refRegistry.has(label)) {
|
|
279
|
-
return errorResult(state, `batch item ${i}: duplicate as label "${label}"`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Resolve "refs" → ids
|
|
285
|
-
if (itemRefs?.length) {
|
|
286
|
-
const resolvedIds: number[] = [];
|
|
287
|
-
for (const ref of itemRefs) {
|
|
288
|
-
const id = refRegistry.get(ref);
|
|
289
|
-
if (id === undefined) {
|
|
290
|
-
return errorResult(state, `batch item ${i}: ref "${ref}" not defined in earlier items`);
|
|
291
|
-
}
|
|
292
|
-
resolvedIds.push(id);
|
|
293
|
-
}
|
|
294
|
-
itemParams.ids = resolvedIds;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Block list/get in batch (read-only ops)
|
|
298
|
-
if ((itemAction as string) === "list" || (itemAction as string) === "get") {
|
|
299
|
-
return errorResult(state, `batch item ${i}: action "${itemAction}" not allowed in batch (use list/get separately)`);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const sub = applyTaskMutation(currentState, itemAction, itemParams as TaskMutationParams);
|
|
303
|
-
if (sub.op.kind === "error") {
|
|
304
|
-
return errorResult(state, `batch item ${i} (${itemAction}): ${(sub.op as { kind: "error"; message: string }).message}`);
|
|
305
|
-
}
|
|
306
|
-
currentState = sub.state;
|
|
307
|
-
|
|
308
|
-
// Register "as" labels after successful create
|
|
309
|
-
if (itemAs?.length && sub.op.kind === "create") {
|
|
310
|
-
sub.op.ids.forEach((id, idx) => {
|
|
311
|
-
if (idx < itemAs.length) {
|
|
312
|
-
refRegistry.set(itemAs[idx], id);
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
results.push({ index: i, op: sub.op as BatchOp });
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return {
|
|
321
|
-
state: currentState,
|
|
322
|
-
op: { kind: "batch", results },
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
1
|
+
import type { Task, TaskAction, TaskMutationParams, TaskStatus } from "../tool/types.js";
|
|
2
|
+
import { isTransitionValid } from "./invariants.js";
|
|
3
|
+
import type { TaskState } from "./state.js";
|
|
4
|
+
import { detectCycle } from "./task-graph.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reducer outcome. Closed tagged union — adding a new action requires extending
|
|
8
|
+
* this union AND the response-envelope's `formatContent` switch (compiler-
|
|
9
|
+
* enforced exhaustive). Mirrors the `Effect` pattern in
|
|
10
|
+
* `packages/rpiv-ask-user-question/state/state-reducer.ts:14-30`.
|
|
11
|
+
*
|
|
12
|
+
* `error` carries the message in-band so callers can pattern-match on
|
|
13
|
+
* `op.kind === "error"` without a side-channel boolean.
|
|
14
|
+
*/
|
|
15
|
+
export type Op =
|
|
16
|
+
| { kind: "create"; ids: number[] }
|
|
17
|
+
| { kind: "update"; results: Array<{ id: number; fromStatus: TaskStatus; toStatus: TaskStatus }> }
|
|
18
|
+
| { kind: "delete"; items: Array<{ id: number; subject: string }> }
|
|
19
|
+
| { kind: "list"; statusFilter?: TaskStatus; includeDeleted: boolean }
|
|
20
|
+
| { kind: "get"; tasks: Task[] }
|
|
21
|
+
| { kind: "clear"; count: number }
|
|
22
|
+
| { kind: "batch"; results: Array<{ index: number; op: Exclude<Op, { kind: "batch" }> }> }
|
|
23
|
+
| { kind: "error"; message: string };
|
|
24
|
+
|
|
25
|
+
export interface ApplyResult {
|
|
26
|
+
state: TaskState;
|
|
27
|
+
op: Op;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function errorResult(state: TaskState, message: string): ApplyResult {
|
|
31
|
+
return { state, op: { kind: "error", message } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pure reducer: (state, action, params) → (state, op). Mirrors the
|
|
36
|
+
* `applyTaskMutation` of pre-refactor `todo.ts` minus content/details
|
|
37
|
+
* formatting; the response envelope (`tool/response-envelope.ts`) owns
|
|
38
|
+
* formatting, the store (`state/store.ts`) owns commit.
|
|
39
|
+
*
|
|
40
|
+
* Validation is in-line: structural guards (`subject required`, `id required`,
|
|
41
|
+
* `at least one mutable field`) plus state-aware checks (transition legality,
|
|
42
|
+
* dangling/deleted blockedBy, self-block, cycles). Decision: validation stays
|
|
43
|
+
* in-reducer — see Plan §Decisions §Decision 2.
|
|
44
|
+
*/
|
|
45
|
+
export function applyTaskMutation(state: TaskState, action: TaskAction, params: TaskMutationParams): ApplyResult {
|
|
46
|
+
switch (action) {
|
|
47
|
+
case "create": {
|
|
48
|
+
// Plural path: subjects[]
|
|
49
|
+
if (params.subjects?.length) {
|
|
50
|
+
const subjects = params.subjects.filter((s): s is string => typeof s === "string" && s.trim().length > 0);
|
|
51
|
+
if (subjects.length === 0) return errorResult(state, "subjects[] requires at least one non-empty subject");
|
|
52
|
+
|
|
53
|
+
const createdIds: number[] = [];
|
|
54
|
+
let cursor = state;
|
|
55
|
+
for (const subject of subjects) {
|
|
56
|
+
const subResult = applyTaskMutation(cursor, "create", {
|
|
57
|
+
...params,
|
|
58
|
+
subject,
|
|
59
|
+
subjects: undefined,
|
|
60
|
+
} as TaskMutationParams);
|
|
61
|
+
if (subResult.op.kind === "error") return subResult;
|
|
62
|
+
cursor = subResult.state;
|
|
63
|
+
if (subResult.op.kind === "create") createdIds.push(...subResult.op.ids);
|
|
64
|
+
}
|
|
65
|
+
return { state: cursor, op: { kind: "create", ids: createdIds } };
|
|
66
|
+
}
|
|
67
|
+
if (!params.subject?.trim()) {
|
|
68
|
+
return errorResult(state, "subject required for create");
|
|
69
|
+
}
|
|
70
|
+
if (params.blockedBy?.length) {
|
|
71
|
+
for (const dep of params.blockedBy) {
|
|
72
|
+
const depTask = state.tasks.find((t) => t.id === dep);
|
|
73
|
+
if (!depTask) return errorResult(state, `blockedBy: #${dep} not found`);
|
|
74
|
+
if (depTask.status === "deleted") return errorResult(state, `blockedBy: #${dep} is deleted`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const newTask: Task = {
|
|
78
|
+
id: state.nextId,
|
|
79
|
+
subject: params.subject,
|
|
80
|
+
status: "pending",
|
|
81
|
+
};
|
|
82
|
+
if (params.description) newTask.description = params.description;
|
|
83
|
+
if (params.activeForm) newTask.activeForm = params.activeForm;
|
|
84
|
+
if (params.blockedBy?.length) newTask.blockedBy = [...params.blockedBy];
|
|
85
|
+
if (params.owner) newTask.owner = params.owner;
|
|
86
|
+
if (params.metadata) newTask.metadata = { ...params.metadata };
|
|
87
|
+
|
|
88
|
+
const newTasks = [...state.tasks, newTask];
|
|
89
|
+
return {
|
|
90
|
+
state: { tasks: newTasks, nextId: state.nextId + 1 },
|
|
91
|
+
op: { kind: "create", ids: [newTask.id] },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case "update": {
|
|
96
|
+
// Plural path: ids[]
|
|
97
|
+
if (params.ids?.length) {
|
|
98
|
+
const results: Array<{ id: number; fromStatus: TaskStatus; toStatus: TaskStatus }> = [];
|
|
99
|
+
let cursor = state;
|
|
100
|
+
for (const id of params.ids) {
|
|
101
|
+
const subResult = applyTaskMutation(cursor, "update", {
|
|
102
|
+
...params,
|
|
103
|
+
id,
|
|
104
|
+
ids: undefined,
|
|
105
|
+
} as TaskMutationParams);
|
|
106
|
+
if (subResult.op.kind === "error") return subResult;
|
|
107
|
+
cursor = subResult.state;
|
|
108
|
+
if (subResult.op.kind === "update") results.push(subResult.op.results[0]);
|
|
109
|
+
}
|
|
110
|
+
return { state: cursor, op: { kind: "update", results } };
|
|
111
|
+
}
|
|
112
|
+
if (params.id === undefined) return errorResult(state, "id required for update");
|
|
113
|
+
const idx = state.tasks.findIndex((t) => t.id === params.id);
|
|
114
|
+
if (idx === -1) return errorResult(state, `#${params.id} not found`);
|
|
115
|
+
const current = state.tasks[idx];
|
|
116
|
+
|
|
117
|
+
const hasMutation =
|
|
118
|
+
params.subject !== undefined ||
|
|
119
|
+
params.description !== undefined ||
|
|
120
|
+
params.activeForm !== undefined ||
|
|
121
|
+
params.status !== undefined ||
|
|
122
|
+
params.owner !== undefined ||
|
|
123
|
+
params.metadata !== undefined ||
|
|
124
|
+
(params.addBlockedBy && params.addBlockedBy.length > 0) ||
|
|
125
|
+
(params.removeBlockedBy && params.removeBlockedBy.length > 0);
|
|
126
|
+
if (!hasMutation) return errorResult(state, "update requires at least one mutable field");
|
|
127
|
+
|
|
128
|
+
let newStatus = current.status;
|
|
129
|
+
if (params.status !== undefined) {
|
|
130
|
+
if (!isTransitionValid(current.status, params.status)) {
|
|
131
|
+
return errorResult(state, `illegal transition ${current.status} → ${params.status}`);
|
|
132
|
+
}
|
|
133
|
+
newStatus = params.status;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let newBlockedBy = current.blockedBy ? [...current.blockedBy] : [];
|
|
137
|
+
if (params.removeBlockedBy?.length) {
|
|
138
|
+
const toRemove = new Set(params.removeBlockedBy);
|
|
139
|
+
newBlockedBy = newBlockedBy.filter((dep) => !toRemove.has(dep));
|
|
140
|
+
}
|
|
141
|
+
if (params.addBlockedBy?.length) {
|
|
142
|
+
for (const dep of params.addBlockedBy) {
|
|
143
|
+
if (dep === current.id) return errorResult(state, `cannot block #${current.id} on itself`);
|
|
144
|
+
const depTask = state.tasks.find((t) => t.id === dep);
|
|
145
|
+
if (!depTask) return errorResult(state, `addBlockedBy: #${dep} not found`);
|
|
146
|
+
if (depTask.status === "deleted") return errorResult(state, `addBlockedBy: #${dep} is deleted`);
|
|
147
|
+
if (!newBlockedBy.includes(dep)) newBlockedBy.push(dep);
|
|
148
|
+
}
|
|
149
|
+
if (detectCycle(state.tasks, current.id, newBlockedBy)) {
|
|
150
|
+
return errorResult(state, "addBlockedBy would create a cycle in the blockedBy graph");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let newMetadata = current.metadata;
|
|
155
|
+
if (params.metadata !== undefined) {
|
|
156
|
+
const merged: Record<string, unknown> = { ...(current.metadata ?? {}) };
|
|
157
|
+
for (const [k, v] of Object.entries(params.metadata)) {
|
|
158
|
+
if (v === null) delete merged[k];
|
|
159
|
+
else merged[k] = v;
|
|
160
|
+
}
|
|
161
|
+
newMetadata = Object.keys(merged).length ? merged : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const updated: Task = { ...current, status: newStatus };
|
|
165
|
+
if (params.subject !== undefined) updated.subject = params.subject;
|
|
166
|
+
if (params.description !== undefined) updated.description = params.description;
|
|
167
|
+
if (params.activeForm !== undefined) updated.activeForm = params.activeForm;
|
|
168
|
+
if (params.owner !== undefined) updated.owner = params.owner;
|
|
169
|
+
if (newBlockedBy.length) updated.blockedBy = newBlockedBy;
|
|
170
|
+
else delete updated.blockedBy;
|
|
171
|
+
if (newMetadata === undefined) delete updated.metadata;
|
|
172
|
+
else updated.metadata = newMetadata;
|
|
173
|
+
|
|
174
|
+
const newTasks = [...state.tasks];
|
|
175
|
+
newTasks[idx] = updated;
|
|
176
|
+
return {
|
|
177
|
+
state: { tasks: newTasks, nextId: state.nextId },
|
|
178
|
+
op: { kind: "update", results: [{ id: updated.id, fromStatus: current.status, toStatus: newStatus }] },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case "list": {
|
|
183
|
+
return {
|
|
184
|
+
state,
|
|
185
|
+
op: {
|
|
186
|
+
kind: "list",
|
|
187
|
+
includeDeleted: params.includeDeleted === true,
|
|
188
|
+
...(params.status !== undefined ? { statusFilter: params.status } : {}),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case "get": {
|
|
194
|
+
// Plural path: ids[]
|
|
195
|
+
if (params.ids?.length) {
|
|
196
|
+
const tasks: Task[] = [];
|
|
197
|
+
let cursor = state;
|
|
198
|
+
for (const id of params.ids) {
|
|
199
|
+
const subResult = applyTaskMutation(cursor, "get", {
|
|
200
|
+
...params,
|
|
201
|
+
id,
|
|
202
|
+
ids: undefined,
|
|
203
|
+
} as TaskMutationParams);
|
|
204
|
+
if (subResult.op.kind === "error") return subResult;
|
|
205
|
+
cursor = subResult.state;
|
|
206
|
+
if (subResult.op.kind === "get") tasks.push(subResult.op.tasks[0]);
|
|
207
|
+
}
|
|
208
|
+
return { state: cursor, op: { kind: "get", tasks } };
|
|
209
|
+
}
|
|
210
|
+
if (params.id === undefined) return errorResult(state, "id required for get");
|
|
211
|
+
const task = state.tasks.find((t) => t.id === params.id);
|
|
212
|
+
if (!task) return errorResult(state, `#${params.id} not found`);
|
|
213
|
+
return { state, op: { kind: "get", tasks: [task] } };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case "delete": {
|
|
217
|
+
// Plural path: ids[]
|
|
218
|
+
if (params.ids?.length) {
|
|
219
|
+
const items: Array<{ id: number; subject: string }> = [];
|
|
220
|
+
let cursor = state;
|
|
221
|
+
for (const id of params.ids) {
|
|
222
|
+
const subResult = applyTaskMutation(cursor, "delete", {
|
|
223
|
+
...params,
|
|
224
|
+
id,
|
|
225
|
+
ids: undefined,
|
|
226
|
+
} as TaskMutationParams);
|
|
227
|
+
if (subResult.op.kind === "error") return subResult;
|
|
228
|
+
cursor = subResult.state;
|
|
229
|
+
if (subResult.op.kind === "delete") items.push(subResult.op.items[0]);
|
|
230
|
+
}
|
|
231
|
+
return { state: cursor, op: { kind: "delete", items } };
|
|
232
|
+
}
|
|
233
|
+
if (params.id === undefined) return errorResult(state, "id required for delete");
|
|
234
|
+
const idx = state.tasks.findIndex((t) => t.id === params.id);
|
|
235
|
+
if (idx === -1) return errorResult(state, `#${params.id} not found`);
|
|
236
|
+
const current = state.tasks[idx];
|
|
237
|
+
if (current.status === "deleted") return errorResult(state, `#${current.id} is already deleted`);
|
|
238
|
+
const updated: Task = { ...current, status: "deleted" };
|
|
239
|
+
const newTasks = [...state.tasks];
|
|
240
|
+
newTasks[idx] = updated;
|
|
241
|
+
return {
|
|
242
|
+
state: { tasks: newTasks, nextId: state.nextId },
|
|
243
|
+
op: { kind: "delete", items: [{ id: updated.id, subject: updated.subject }] },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case "clear": {
|
|
248
|
+
const count = state.tasks.length;
|
|
249
|
+
return {
|
|
250
|
+
state: { tasks: [], nextId: 1 },
|
|
251
|
+
op: { kind: "clear", count },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case "batch": {
|
|
256
|
+
if (!params.items?.length) {
|
|
257
|
+
return errorResult(state, "batch requires at least one item in items[]");
|
|
258
|
+
}
|
|
259
|
+
type BatchOp = Exclude<Op, { kind: "batch" }>;
|
|
260
|
+
const results: Array<{ index: number; op: BatchOp }> = [];
|
|
261
|
+
let currentState = state;
|
|
262
|
+
const refRegistry = new Map<string, number>();
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < params.items.length; i++) {
|
|
265
|
+
const item = params.items[i];
|
|
266
|
+
const { action: itemAction, as: itemAs, refs: itemRefs, ...itemParams } = item;
|
|
267
|
+
|
|
268
|
+
// Validate "as" labels
|
|
269
|
+
if (itemAs?.length) {
|
|
270
|
+
if (itemAction !== "create") {
|
|
271
|
+
return errorResult(state, `batch item ${i}: "as" only valid for create actions`);
|
|
272
|
+
}
|
|
273
|
+
const taskCount = item.subjects?.length ?? 1;
|
|
274
|
+
if (itemAs.length !== taskCount) {
|
|
275
|
+
return errorResult(state, `batch item ${i}: "as" (${itemAs.length}) must match task count (${taskCount})`);
|
|
276
|
+
}
|
|
277
|
+
for (const label of itemAs) {
|
|
278
|
+
if (refRegistry.has(label)) {
|
|
279
|
+
return errorResult(state, `batch item ${i}: duplicate as label "${label}"`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Resolve "refs" → ids
|
|
285
|
+
if (itemRefs?.length) {
|
|
286
|
+
const resolvedIds: number[] = [];
|
|
287
|
+
for (const ref of itemRefs) {
|
|
288
|
+
const id = refRegistry.get(ref);
|
|
289
|
+
if (id === undefined) {
|
|
290
|
+
return errorResult(state, `batch item ${i}: ref "${ref}" not defined in earlier items`);
|
|
291
|
+
}
|
|
292
|
+
resolvedIds.push(id);
|
|
293
|
+
}
|
|
294
|
+
itemParams.ids = resolvedIds;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Block list/get in batch (read-only ops)
|
|
298
|
+
if ((itemAction as string) === "list" || (itemAction as string) === "get") {
|
|
299
|
+
return errorResult(state, `batch item ${i}: action "${itemAction}" not allowed in batch (use list/get separately)`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const sub = applyTaskMutation(currentState, itemAction, itemParams as TaskMutationParams);
|
|
303
|
+
if (sub.op.kind === "error") {
|
|
304
|
+
return errorResult(state, `batch item ${i} (${itemAction}): ${(sub.op as { kind: "error"; message: string }).message}`);
|
|
305
|
+
}
|
|
306
|
+
currentState = sub.state;
|
|
307
|
+
|
|
308
|
+
// Register "as" labels after successful create
|
|
309
|
+
if (itemAs?.length && sub.op.kind === "create") {
|
|
310
|
+
sub.op.ids.forEach((id, idx) => {
|
|
311
|
+
if (idx < itemAs.length) {
|
|
312
|
+
refRegistry.set(itemAs[idx], id);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
results.push({ index: i, op: sub.op as BatchOp });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
state: currentState,
|
|
322
|
+
op: { kind: "batch", results },
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|