@trevonistrevon/pi-loop 0.4.11 → 0.5.1
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 +20 -9
- package/dist/commands/loop-command.d.ts +22 -0
- package/dist/commands/loop-command.js +148 -0
- package/dist/commands/tasks-command.d.ts +15 -0
- package/dist/commands/tasks-command.js +117 -0
- package/dist/coordinator.d.ts +35 -0
- package/dist/coordinator.js +56 -0
- package/dist/goal-coordinator.d.ts +22 -0
- package/dist/goal-coordinator.js +28 -0
- package/dist/goal-reducer.d.ts +89 -0
- package/dist/goal-reducer.js +181 -0
- package/dist/goal-store.d.ts +31 -0
- package/dist/goal-store.js +298 -0
- package/dist/goal-types.d.ts +82 -0
- package/dist/goal-types.js +1 -0
- package/dist/goal-verifier.d.ts +20 -0
- package/dist/goal-verifier.js +198 -0
- package/dist/index.js +130 -1087
- package/dist/loop-reducer.d.ts +63 -0
- package/dist/loop-reducer.js +67 -0
- package/dist/monitor-completion-coordinator.d.ts +10 -0
- package/dist/monitor-completion-coordinator.js +13 -0
- package/dist/monitor-manager.d.ts +2 -0
- package/dist/monitor-manager.js +107 -29
- package/dist/monitor-reducer.d.ts +82 -0
- package/dist/monitor-reducer.js +69 -0
- package/dist/notification-reducer.d.ts +81 -0
- package/dist/notification-reducer.js +65 -0
- package/dist/runtime/monitor-ondone-runtime.d.ts +13 -0
- package/dist/runtime/monitor-ondone-runtime.js +49 -0
- package/dist/runtime/notification-runtime.d.ts +34 -0
- package/dist/runtime/notification-runtime.js +152 -0
- package/dist/runtime/scope.d.ts +8 -0
- package/dist/runtime/scope.js +33 -0
- package/dist/runtime/session-runtime.d.ts +39 -0
- package/dist/runtime/session-runtime.js +110 -0
- package/dist/runtime/task-backlog-runtime.d.ts +36 -0
- package/dist/runtime/task-backlog-runtime.js +105 -0
- package/dist/runtime/task-rpc.d.ts +19 -0
- package/dist/runtime/task-rpc.js +118 -0
- package/dist/store.d.ts +7 -4
- package/dist/store.js +129 -49
- package/dist/task-backlog-coordinator.d.ts +12 -0
- package/dist/task-backlog-coordinator.js +22 -0
- package/dist/task-reducer.d.ts +66 -0
- package/dist/task-reducer.js +76 -0
- package/dist/task-store.d.ts +8 -4
- package/dist/task-store.js +102 -33
- package/dist/tools/loop-tools.d.ts +41 -0
- package/dist/tools/loop-tools.js +241 -0
- package/dist/tools/monitor-tools.d.ts +25 -0
- package/dist/tools/monitor-tools.js +110 -0
- package/dist/tools/native-task-tools.d.ts +15 -0
- package/dist/tools/native-task-tools.js +127 -0
- package/docs/architecture/goal-state-schema.md +505 -0
- package/docs/architecture/state-machine-migration.md +546 -0
- package/docs/architecture/state-machine-reducer-event-model.md +823 -0
- package/docs/architecture/state-machine-test-matrix.md +249 -0
- package/docs/architecture/state-machine-transition-map.md +436 -0
- package/package.json +1 -1
- package/src/commands/loop-command.ts +184 -0
- package/src/commands/tasks-command.ts +135 -0
- package/src/coordinator.ts +115 -0
- package/src/goal-coordinator.ts +58 -0
- package/src/goal-reducer.ts +280 -0
- package/src/goal-store.ts +315 -0
- package/src/goal-types.ts +104 -0
- package/src/goal-verifier.ts +241 -0
- package/src/index.ts +134 -1147
- package/src/loop-reducer.ts +148 -0
- package/src/monitor-completion-coordinator.ts +24 -0
- package/src/monitor-manager.ts +115 -27
- package/src/monitor-reducer.ts +166 -0
- package/src/notification-reducer.ts +155 -0
- package/src/runtime/monitor-ondone-runtime.ts +75 -0
- package/src/runtime/notification-runtime.ts +212 -0
- package/src/runtime/scope.ts +37 -0
- package/src/runtime/session-runtime.ts +168 -0
- package/src/runtime/task-backlog-runtime.ts +163 -0
- package/src/runtime/task-rpc.ts +150 -0
- package/src/store.ts +132 -50
- package/src/task-backlog-coordinator.ts +32 -0
- package/src/task-reducer.ts +152 -0
- package/src/task-store.ts +103 -31
- package/src/tools/loop-tools.ts +304 -0
- package/src/tools/monitor-tools.ts +145 -0
- package/src/tools/native-task-tools.ts +144 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionUIContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { parseInterval } from "../loop-parse.js";
|
|
7
|
+
import type { LoopEntry, Trigger } from "../types.js";
|
|
8
|
+
|
|
9
|
+
interface LoopStoreLike {
|
|
10
|
+
list(): LoopEntry[];
|
|
11
|
+
get(id: string): LoopEntry | undefined;
|
|
12
|
+
create(trigger: Trigger, prompt: string, options?: Partial<LoopEntry>): LoopEntry;
|
|
13
|
+
pause(id: string): LoopEntry | undefined;
|
|
14
|
+
resume(id: string): LoopEntry | undefined;
|
|
15
|
+
delete(id: string): boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TriggerSystemLike {
|
|
19
|
+
add(entry: LoopEntry): void;
|
|
20
|
+
remove(id: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LoopCommandOptions {
|
|
24
|
+
pi: ExtensionAPI;
|
|
25
|
+
getStore: () => LoopStoreLike;
|
|
26
|
+
getTriggerSystem: () => TriggerSystemLike;
|
|
27
|
+
updateWidget: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
31
|
+
const { pi, getStore, getTriggerSystem, updateWidget } = options;
|
|
32
|
+
|
|
33
|
+
async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
34
|
+
const p = prompt || await ui.input("Prompt (what should the agent check?)");
|
|
35
|
+
if (!p) return;
|
|
36
|
+
|
|
37
|
+
const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
|
|
38
|
+
if (!interval) return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const parsed = parseInterval(interval);
|
|
42
|
+
const trigger: Trigger = { type: "cron", schedule: parsed.cron };
|
|
43
|
+
const entry = getStore().create(trigger, p, { recurring: true });
|
|
44
|
+
getTriggerSystem().add(entry);
|
|
45
|
+
updateWidget();
|
|
46
|
+
ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
|
|
47
|
+
} catch (err: unknown) {
|
|
48
|
+
ui.notify((err as Error).message, "error");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function eventLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
53
|
+
const p = prompt || await ui.input("Prompt");
|
|
54
|
+
if (!p) return;
|
|
55
|
+
|
|
56
|
+
const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
|
|
57
|
+
if (!source) return;
|
|
58
|
+
|
|
59
|
+
const trigger: Trigger = { type: "event", source };
|
|
60
|
+
const entry = getStore().create(trigger, p, { recurring: true });
|
|
61
|
+
getTriggerSystem().add(entry);
|
|
62
|
+
updateWidget();
|
|
63
|
+
ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function viewLoops(ui: ExtensionUIContext) {
|
|
67
|
+
const loops = getStore().list();
|
|
68
|
+
if (loops.length === 0) {
|
|
69
|
+
await ui.select("No active loops", ["< Back"]);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const choices = loops.map((l) => {
|
|
74
|
+
const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
|
|
75
|
+
const triggerDesc = l.trigger.type === "cron"
|
|
76
|
+
? `cron: ${l.trigger.schedule}`
|
|
77
|
+
: l.trigger.type === "event"
|
|
78
|
+
? `event: ${l.trigger.source}`
|
|
79
|
+
: `hybrid: ${l.trigger.cron}`;
|
|
80
|
+
return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
|
|
81
|
+
});
|
|
82
|
+
choices.push("< Back");
|
|
83
|
+
|
|
84
|
+
const selected = await ui.select("Active Loops", choices);
|
|
85
|
+
if (!selected || selected === "< Back") return;
|
|
86
|
+
|
|
87
|
+
const match = selected.match(/#(\d+)/);
|
|
88
|
+
if (match) {
|
|
89
|
+
const entry = getStore().get(match[1]);
|
|
90
|
+
if (entry) {
|
|
91
|
+
const actions = ["x Delete"];
|
|
92
|
+
if (entry.status === "active") actions.unshift("- Pause");
|
|
93
|
+
else if (entry.status === "paused") actions.unshift("* Resume");
|
|
94
|
+
actions.push("< Back");
|
|
95
|
+
|
|
96
|
+
const action = await ui.select(
|
|
97
|
+
`#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`,
|
|
98
|
+
actions,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (action === "x Delete") {
|
|
102
|
+
getTriggerSystem().remove(entry.id);
|
|
103
|
+
getStore().delete(entry.id);
|
|
104
|
+
updateWidget();
|
|
105
|
+
ui.notify(`Loop #${entry.id} deleted`, "info");
|
|
106
|
+
} else if (action === "- Pause") {
|
|
107
|
+
getStore().pause(entry.id);
|
|
108
|
+
getTriggerSystem().remove(entry.id);
|
|
109
|
+
updateWidget();
|
|
110
|
+
ui.notify(`Loop #${entry.id} paused`, "info");
|
|
111
|
+
} else if (action === "* Resume") {
|
|
112
|
+
getStore().resume(entry.id);
|
|
113
|
+
getTriggerSystem().add(entry);
|
|
114
|
+
updateWidget();
|
|
115
|
+
ui.notify(`Loop #${entry.id} resumed`, "info");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return viewLoops(ui);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function settings(ui: ExtensionUIContext) {
|
|
124
|
+
const loops = getStore().list();
|
|
125
|
+
const active = loops.filter((l) => l.status === "active").length;
|
|
126
|
+
ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pi.registerCommand("loop", {
|
|
130
|
+
description: "Create a repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
|
|
131
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
132
|
+
const trimmed = args.trim();
|
|
133
|
+
const ui = ctx.ui;
|
|
134
|
+
|
|
135
|
+
if (!trimmed) {
|
|
136
|
+
const choice = await ui.select("Loop", [
|
|
137
|
+
"Create scheduled loop",
|
|
138
|
+
"Create event-triggered loop",
|
|
139
|
+
"View active loops",
|
|
140
|
+
"Settings",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
if (!choice) return;
|
|
144
|
+
if (choice.startsWith("Create scheduled")) return scheduleLoop(ui);
|
|
145
|
+
if (choice.startsWith("Create event")) return eventLoop(ui);
|
|
146
|
+
if (choice.startsWith("View active")) return viewLoops(ui);
|
|
147
|
+
return settings(ui);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
|
|
151
|
+
if (intervalMatch) {
|
|
152
|
+
const interval = intervalMatch[1];
|
|
153
|
+
const prompt = trimmed.slice(intervalMatch[0].length).trim();
|
|
154
|
+
|
|
155
|
+
if (!prompt) {
|
|
156
|
+
ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const parsed = parseInterval(interval);
|
|
162
|
+
const trigger: Trigger = { type: "cron", schedule: parsed.cron };
|
|
163
|
+
const entry = getStore().create(trigger, prompt, { recurring: true });
|
|
164
|
+
getTriggerSystem().add(entry);
|
|
165
|
+
updateWidget();
|
|
166
|
+
ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
|
|
167
|
+
} catch (err: unknown) {
|
|
168
|
+
ui.notify((err as Error).message, "error");
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const choice = await ui.select("Loop mode", [
|
|
174
|
+
`Scheduled: "${trimmed.slice(0, 50)}"`,
|
|
175
|
+
`Event-triggered: "${trimmed.slice(0, 50)}"`,
|
|
176
|
+
`Self-paced: "${trimmed.slice(0, 50)}"`,
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
if (!choice) return;
|
|
180
|
+
if (choice.startsWith("Event")) return eventLoop(ui, trimmed);
|
|
181
|
+
return scheduleLoop(ui, trimmed);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { TaskStore } from "../task-store.js";
|
|
3
|
+
import type { TaskEntry } from "../task-types.js";
|
|
4
|
+
|
|
5
|
+
export interface TaskBacklogResult {
|
|
6
|
+
created: boolean;
|
|
7
|
+
entry?: { id: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TasksCommandOptions {
|
|
11
|
+
pi: ExtensionAPI;
|
|
12
|
+
getNativeTaskStore: () => TaskStore | undefined;
|
|
13
|
+
evaluateTaskBacklog: (taskStore: TaskStore, pendingCount: number) => Promise<TaskBacklogResult>;
|
|
14
|
+
updateWidget: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function registerTasksCommand(options: TasksCommandOptions): void {
|
|
18
|
+
const { pi, getNativeTaskStore, evaluateTaskBacklog, updateWidget } = options;
|
|
19
|
+
|
|
20
|
+
async function emitCreated(entry: TaskEntry) {
|
|
21
|
+
pi.events.emit("tasks:created", {
|
|
22
|
+
taskId: entry.id,
|
|
23
|
+
subject: entry.subject,
|
|
24
|
+
description: entry.description,
|
|
25
|
+
status: entry.status,
|
|
26
|
+
});
|
|
27
|
+
const taskStore = getNativeTaskStore();
|
|
28
|
+
if (!taskStore) return { created: false } satisfies TaskBacklogResult;
|
|
29
|
+
const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
30
|
+
updateWidget();
|
|
31
|
+
return backlog;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function createNativeTaskInteractively(ui: ExtensionUIContext) {
|
|
35
|
+
const taskStore = getNativeTaskStore();
|
|
36
|
+
if (!taskStore) {
|
|
37
|
+
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const subject = await ui.input("Task subject");
|
|
42
|
+
if (!subject) return;
|
|
43
|
+
const description = await ui.input("Task description") || subject;
|
|
44
|
+
const entry = taskStore.create(subject, description);
|
|
45
|
+
const backlog = await emitCreated(entry);
|
|
46
|
+
ui.notify(`Task #${entry.id} created`, "info");
|
|
47
|
+
if (backlog.created && backlog.entry) {
|
|
48
|
+
ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function viewNativeTasks(ui: ExtensionUIContext): Promise<void> {
|
|
53
|
+
const taskStore = getNativeTaskStore();
|
|
54
|
+
if (!taskStore) {
|
|
55
|
+
ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tasks = taskStore.list();
|
|
60
|
+
const choices = tasks.map((task) => {
|
|
61
|
+
const icon = task.status === "in_progress" ? ">" : task.status === "completed" ? "ok" : "*";
|
|
62
|
+
return `${icon} #${task.id} [${task.status}] ${task.subject.slice(0, 60)}`;
|
|
63
|
+
});
|
|
64
|
+
choices.unshift("+ Create task");
|
|
65
|
+
choices.push("< Back");
|
|
66
|
+
|
|
67
|
+
const selected = await ui.select("Native Tasks", choices);
|
|
68
|
+
if (!selected || selected === "< Back") return;
|
|
69
|
+
if (selected === "+ Create task") {
|
|
70
|
+
await createNativeTaskInteractively(ui);
|
|
71
|
+
return viewNativeTasks(ui);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const match = selected.match(/#(\d+)/);
|
|
75
|
+
if (!match) return viewNativeTasks(ui);
|
|
76
|
+
|
|
77
|
+
const task = taskStore.get(match[1]);
|
|
78
|
+
if (!task) return viewNativeTasks(ui);
|
|
79
|
+
|
|
80
|
+
const actions = ["x Delete"];
|
|
81
|
+
if (task.status === "pending") {
|
|
82
|
+
actions.unshift("ok Complete");
|
|
83
|
+
actions.unshift("> Start");
|
|
84
|
+
} else if (task.status === "in_progress") {
|
|
85
|
+
actions.unshift("ok Complete");
|
|
86
|
+
actions.unshift("* Return to pending");
|
|
87
|
+
} else {
|
|
88
|
+
actions.unshift("* Reopen");
|
|
89
|
+
}
|
|
90
|
+
actions.push("< Back");
|
|
91
|
+
|
|
92
|
+
const action = await ui.select(`#${task.id}: ${task.subject}\n\n${task.description}`, actions);
|
|
93
|
+
if (!action || action === "< Back") return viewNativeTasks(ui);
|
|
94
|
+
|
|
95
|
+
if (action === "x Delete") {
|
|
96
|
+
taskStore.delete(task.id);
|
|
97
|
+
ui.notify(`Task #${task.id} deleted`, "info");
|
|
98
|
+
} else if (action === "> Start") {
|
|
99
|
+
taskStore.start(task.id);
|
|
100
|
+
ui.notify(`Task #${task.id} started`, "info");
|
|
101
|
+
} else if (action === "ok Complete") {
|
|
102
|
+
taskStore.complete(task.id);
|
|
103
|
+
ui.notify(`Task #${task.id} completed`, "info");
|
|
104
|
+
} else if (action === "* Return to pending" || action === "* Reopen") {
|
|
105
|
+
taskStore.reopen(task.id);
|
|
106
|
+
ui.notify(`Task #${task.id} reopened`, "info");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
updateWidget();
|
|
110
|
+
await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
111
|
+
return viewNativeTasks(ui);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
pi.registerCommand("tasks", {
|
|
115
|
+
description: "View or manage native pi-loop tasks when pi-tasks is not installed",
|
|
116
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
117
|
+
const trimmed = args.trim();
|
|
118
|
+
const taskStore = getNativeTaskStore();
|
|
119
|
+
if (!taskStore) {
|
|
120
|
+
ctx.ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (trimmed) {
|
|
124
|
+
const entry = taskStore.create(trimmed.slice(0, 80), trimmed);
|
|
125
|
+
const backlog = await emitCreated(entry);
|
|
126
|
+
ctx.ui.notify(`Task #${entry.id} created`, "info");
|
|
127
|
+
if (backlog.created && backlog.entry) {
|
|
128
|
+
ctx.ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await viewNativeTasks(ctx.ui);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export type ReducerSource =
|
|
2
|
+
| "tool"
|
|
3
|
+
| "command"
|
|
4
|
+
| "scheduler"
|
|
5
|
+
| "eventbus"
|
|
6
|
+
| "monitor"
|
|
7
|
+
| "session"
|
|
8
|
+
| "coordinator"
|
|
9
|
+
| "system";
|
|
10
|
+
|
|
11
|
+
export type ReducerEntityType = "task" | "loop" | "monitor" | "notification" | "goal";
|
|
12
|
+
|
|
13
|
+
export interface ReducerEvent<TType extends string = string, TPayload = unknown> {
|
|
14
|
+
type: TType;
|
|
15
|
+
at: number;
|
|
16
|
+
source: ReducerSource;
|
|
17
|
+
entityType?: ReducerEntityType;
|
|
18
|
+
entityId?: string;
|
|
19
|
+
payload: TPayload;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ReducerEffect<TEffect extends string = string, TPayload = unknown> {
|
|
23
|
+
type: TEffect;
|
|
24
|
+
entityType?: ReducerEntityType;
|
|
25
|
+
entityId?: string;
|
|
26
|
+
payload: TPayload;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type DispatchEventEffect = ReducerEffect<"DISPATCH_EVENT", { event: ReducerEvent }>;
|
|
30
|
+
export type AnyReducerEffect = ReducerEffect | DispatchEventEffect;
|
|
31
|
+
|
|
32
|
+
export type ReducerHandler =
|
|
33
|
+
(event: ReducerEvent) => undefined | AnyReducerEffect[] | Promise<undefined | AnyReducerEffect[]>;
|
|
34
|
+
|
|
35
|
+
export type EffectHandler =
|
|
36
|
+
(effect: ReducerEffect) => void | Promise<void>;
|
|
37
|
+
|
|
38
|
+
export interface CoordinatorOptions {
|
|
39
|
+
reducers: ReducerHandler[];
|
|
40
|
+
effectHandlers?: Partial<Record<string, EffectHandler>>;
|
|
41
|
+
effectExecutor?: EffectHandler;
|
|
42
|
+
maxDispatchDepth?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class CoordinatorError extends Error {
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "CoordinatorError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Coordinator {
|
|
53
|
+
dispatch(event: ReducerEvent): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createCoordinator(options: CoordinatorOptions): Coordinator {
|
|
57
|
+
const {
|
|
58
|
+
reducers,
|
|
59
|
+
effectHandlers = {},
|
|
60
|
+
effectExecutor,
|
|
61
|
+
maxDispatchDepth = 100,
|
|
62
|
+
} = options;
|
|
63
|
+
|
|
64
|
+
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
|
65
|
+
return typeof value === "object" && value !== null && "then" in value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function executeEffect(effect: AnyReducerEffect, depth: number): Promise<void> {
|
|
69
|
+
if (effect.type === "DISPATCH_EVENT") {
|
|
70
|
+
const dispatchEffect = effect as DispatchEventEffect;
|
|
71
|
+
const derivedEvent = dispatchEffect.payload.event;
|
|
72
|
+
if (!derivedEvent) {
|
|
73
|
+
throw new CoordinatorError("DISPATCH_EVENT effect missing payload.event");
|
|
74
|
+
}
|
|
75
|
+
await dispatchAtDepth(derivedEvent, depth + 1);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const specificHandler = effectHandlers[effect.type];
|
|
80
|
+
if (specificHandler) {
|
|
81
|
+
const handled = specificHandler(effect);
|
|
82
|
+
if (isPromiseLike(handled)) await handled;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (effectExecutor) {
|
|
87
|
+
const handled = effectExecutor(effect);
|
|
88
|
+
if (isPromiseLike(handled)) await handled;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function dispatchAtDepth(event: ReducerEvent, depth: number): Promise<void> {
|
|
93
|
+
if (depth > maxDispatchDepth) {
|
|
94
|
+
throw new CoordinatorError(`Maximum dispatch depth exceeded (${maxDispatchDepth})`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const effects: AnyReducerEffect[] = [];
|
|
98
|
+
for (const reducer of reducers) {
|
|
99
|
+
const emitted = reducer(event);
|
|
100
|
+
const resolved = isPromiseLike(emitted) ? await emitted : emitted;
|
|
101
|
+
if (!resolved || resolved.length === 0) continue;
|
|
102
|
+
effects.push(...resolved);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const effect of effects) {
|
|
106
|
+
await executeEffect(effect, depth);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
async dispatch(event: ReducerEvent): Promise<void> {
|
|
112
|
+
await dispatchAtDepth(event, 1);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReducerEffect, ReducerEvent } from "./coordinator.js";
|
|
2
|
+
import type { GoalReducerState } from "./goal-types.js";
|
|
3
|
+
import { verifyGoal } from "./goal-verifier.js";
|
|
4
|
+
import type { LoopReducerState } from "./loop-reducer.js";
|
|
5
|
+
import type { MonitorReducerState } from "./monitor-reducer.js";
|
|
6
|
+
import type { TaskReducerState } from "./task-reducer.js";
|
|
7
|
+
|
|
8
|
+
export type GoalVerificationRequestedEvent = ReducerEvent<
|
|
9
|
+
"GOAL_VERIFICATION_REQUESTED",
|
|
10
|
+
{ id: string }
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
export interface GoalCoordinatorSnapshot {
|
|
14
|
+
goalState: GoalReducerState;
|
|
15
|
+
taskState: TaskReducerState;
|
|
16
|
+
loopState: LoopReducerState;
|
|
17
|
+
monitorState: MonitorReducerState;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function reduceGoalVerificationRequest(
|
|
21
|
+
event: GoalVerificationRequestedEvent,
|
|
22
|
+
snapshot: GoalCoordinatorSnapshot,
|
|
23
|
+
): ReducerEffect[] {
|
|
24
|
+
if (event.type !== "GOAL_VERIFICATION_REQUESTED") return [];
|
|
25
|
+
|
|
26
|
+
const goal = snapshot.goalState.goalsById[event.payload.id];
|
|
27
|
+
if (!goal) return [];
|
|
28
|
+
|
|
29
|
+
return verifyGoal({
|
|
30
|
+
goal,
|
|
31
|
+
taskState: snapshot.taskState,
|
|
32
|
+
loopState: snapshot.loopState,
|
|
33
|
+
monitorState: snapshot.monitorState,
|
|
34
|
+
at: event.at,
|
|
35
|
+
}).effects;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GoalCoordinatorOptions {
|
|
39
|
+
getGoalState: () => GoalReducerState;
|
|
40
|
+
getTaskState: () => TaskReducerState;
|
|
41
|
+
getLoopState: () => LoopReducerState;
|
|
42
|
+
getMonitorState: () => MonitorReducerState;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createGoalCoordinatorReducer(options: GoalCoordinatorOptions) {
|
|
46
|
+
const { getGoalState, getTaskState, getLoopState, getMonitorState } = options;
|
|
47
|
+
|
|
48
|
+
return (event: ReducerEvent): ReducerEffect[] => {
|
|
49
|
+
if (event.type !== "GOAL_VERIFICATION_REQUESTED") return [];
|
|
50
|
+
|
|
51
|
+
return reduceGoalVerificationRequest(event as GoalVerificationRequestedEvent, {
|
|
52
|
+
goalState: getGoalState(),
|
|
53
|
+
taskState: getTaskState(),
|
|
54
|
+
loopState: getLoopState(),
|
|
55
|
+
monitorState: getMonitorState(),
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
}
|