@trevonistrevon/pi-loop 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -2
- package/dist/api.d.ts +2 -1
- package/dist/index.js +14 -0
- package/dist/loop-reducer.d.ts +24 -1
- package/dist/loop-reducer.js +31 -0
- package/dist/notification-reducer.d.ts +4 -1
- package/dist/runtime/notification-runtime.d.ts +4 -1
- package/dist/runtime/notification-runtime.js +27 -1
- package/dist/runtime/session-runtime.js +14 -3
- package/dist/runtime/task-backlog-runtime.d.ts +1 -1
- package/dist/runtime/task-backlog-runtime.js +1 -1
- package/dist/runtime/task-events.d.ts +2 -1
- package/dist/runtime/task-events.js +1 -0
- package/dist/runtime/task-rpc.d.ts +4 -0
- package/dist/runtime/task-rpc.js +62 -1
- package/dist/store.d.ts +10 -1
- package/dist/store.js +51 -0
- package/dist/task-reducer.d.ts +2 -1
- package/dist/task-reducer.js +1 -0
- package/dist/task-store.d.ts +2 -2
- package/dist/task-store.js +2 -2
- package/dist/task-types.d.ts +6 -0
- package/dist/tools/loop-tools.d.ts +17 -1
- package/dist/tools/loop-tools.js +331 -26
- package/dist/tools/monitor-tools.js +43 -9
- package/dist/tools/native-task-tools.js +56 -11
- package/dist/tools/tool-result.d.ts +12 -2
- package/dist/tools/tool-result.js +7 -3
- package/dist/types.d.ts +35 -0
- package/dist/ui/tool-renderer.d.ts +7 -0
- package/dist/ui/tool-renderer.js +34 -0
- package/dist/ui/widget.js +4 -4
- package/dist/workflow-reducer.d.ts +18 -0
- package/dist/workflow-reducer.js +82 -0
- package/docs/USAGE_GUIDE.md +41 -0
- package/package.json +3 -3
- package/src/api.ts +9 -1
- package/src/index.ts +14 -0
- package/src/loop-reducer.ts +53 -1
- package/src/notification-reducer.ts +4 -1
- package/src/runtime/notification-runtime.ts +34 -2
- package/src/runtime/session-runtime.ts +15 -3
- package/src/runtime/task-backlog-runtime.ts +1 -1
- package/src/runtime/task-events.ts +3 -1
- package/src/runtime/task-rpc.ts +66 -0
- package/src/store.ts +51 -2
- package/src/task-reducer.ts +3 -1
- package/src/task-store.ts +3 -3
- package/src/task-types.ts +7 -0
- package/src/tools/loop-tools.ts +376 -20
- package/src/tools/monitor-tools.ts +44 -7
- package/src/tools/native-task-tools.ts +56 -8
- package/src/tools/tool-result.ts +18 -2
- package/src/types.ts +41 -0
- package/src/ui/tool-renderer.ts +45 -0
- package/src/ui/widget.ts +4 -4
- package/src/workflow-reducer.ts +107 -0
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type ReducerNotification,
|
|
13
13
|
reduceNotificationState,
|
|
14
14
|
} from "../notification-reducer.js";
|
|
15
|
-
import type { DynamicLoopState, Trigger } from "../types.js";
|
|
15
|
+
import type { DynamicLoopState, Trigger, WorkflowRunState } from "../types.js";
|
|
16
16
|
|
|
17
17
|
export interface LoopFireEvent {
|
|
18
18
|
loopId: string;
|
|
@@ -21,8 +21,11 @@ export interface LoopFireEvent {
|
|
|
21
21
|
timestamp: number;
|
|
22
22
|
readOnly?: boolean;
|
|
23
23
|
recurring?: boolean;
|
|
24
|
+
persistent?: boolean;
|
|
24
25
|
autoTask?: boolean;
|
|
26
|
+
taskBacklog?: boolean;
|
|
25
27
|
dynamic?: DynamicLoopState;
|
|
28
|
+
workflow?: WorkflowRunState;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export interface PendingNotification extends LoopFireEvent {
|
|
@@ -104,6 +107,24 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
|
|
|
104
107
|
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
105
108
|
: "";
|
|
106
109
|
|
|
110
|
+
if (data.workflow) {
|
|
111
|
+
const state = data.workflow.definition.states[data.workflow.currentState];
|
|
112
|
+
const outcomes = Object.keys(state?.on ?? {});
|
|
113
|
+
const lines = [
|
|
114
|
+
`[pi-loop] Loop #${loopId} fired (workflow).${constraint}`,
|
|
115
|
+
`Goal: ${data.prompt || data.workflow.definition.initialState}`,
|
|
116
|
+
`State: ${data.workflow.currentState}`,
|
|
117
|
+
];
|
|
118
|
+
if (state?.prompt) lines.push(`State instructions: ${state.prompt}`);
|
|
119
|
+
if (data.workflow.activeTaskId) lines.push(`Active task: #${data.workflow.activeTaskId}`);
|
|
120
|
+
if (outcomes.length > 0) lines.push(`Allowed outcomes: ${outcomes.join(", ")}`);
|
|
121
|
+
lines.push(
|
|
122
|
+
`Workflow lifecycle: Loop #${loopId} is an opt-in state controller. Do not call LoopDelete after this state.`,
|
|
123
|
+
"Before ending this turn, call WorkflowTransition exactly once with this workflow id and one allowed outcome. Include evidence for the branch decision. Terminal outcomes complete or pause the workflow automatically.",
|
|
124
|
+
);
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
107
128
|
if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
|
|
108
129
|
const dynamic = data.dynamic;
|
|
109
130
|
const lines = [
|
|
@@ -115,14 +136,22 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
|
|
|
115
136
|
if (dynamic?.metrics) lines.push(`Metrics: ${dynamic.metrics}`);
|
|
116
137
|
if (dynamic?.doneCriteria) lines.push(`Done criteria: ${dynamic.doneCriteria}`);
|
|
117
138
|
lines.push(
|
|
118
|
-
|
|
139
|
+
`Loop lifecycle: Loop #${loopId} is the persistent controller for the overall goal. Do not call LoopDelete after this iteration.`,
|
|
140
|
+
"Before ending this turn, call LoopUpdate exactly once: use status=\"completed\" only when the overall goal and done criteria are satisfied; use status=\"continue\" when any work remains, with state/metrics and optional nextInterval; use status=\"paused\" only when genuinely blocked. Omit nextInterval for an idle-driven rewake.",
|
|
119
141
|
);
|
|
120
142
|
return lines.join("\n");
|
|
121
143
|
}
|
|
122
144
|
|
|
145
|
+
const lifecycle = data.taskBacklog
|
|
146
|
+
? `Backlog lifecycle: Loop #${loopId} is managed automatically. Do not call LoopDelete; when no pending tasks remain, report that and end this iteration.`
|
|
147
|
+
: (data.persistent ?? data.recurring)
|
|
148
|
+
? `Loop lifecycle: Loop #${loopId} is recurring and remains active after this iteration. Do not call LoopDelete or pause it merely because this run finished, found no changes, or has no immediate work. Stop it only when the user or the loop prompt explicitly requires cancellation.`
|
|
149
|
+
: `Loop lifecycle: Loop #${loopId} is a one-shot wake and cleanup is automatic. Do not call LoopDelete.`;
|
|
150
|
+
|
|
123
151
|
return [
|
|
124
152
|
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
125
153
|
prompt,
|
|
154
|
+
lifecycle,
|
|
126
155
|
].join("\n");
|
|
127
156
|
}
|
|
128
157
|
|
|
@@ -154,9 +183,12 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
|
|
|
154
183
|
loopId: notification.loopId,
|
|
155
184
|
trigger: notification.trigger,
|
|
156
185
|
recurring: notification.recurring,
|
|
186
|
+
persistent: notification.persistent,
|
|
157
187
|
readOnly: notification.readOnly,
|
|
158
188
|
autoTask: notification.autoTask,
|
|
189
|
+
taskBacklog: notification.taskBacklog,
|
|
159
190
|
dynamic: notification.dynamic,
|
|
191
|
+
workflow: notification.workflow,
|
|
160
192
|
timestamp: notification.timestamp,
|
|
161
193
|
},
|
|
162
194
|
}, {
|
|
@@ -62,8 +62,11 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
|
|
|
62
62
|
if (heartbeatTimer) return;
|
|
63
63
|
heartbeatTimer = setInterval(() => {
|
|
64
64
|
// Swallow pump failures so a transient error never surfaces as an
|
|
65
|
-
// unhandled rejection;
|
|
66
|
-
void pumpLoops()
|
|
65
|
+
// unhandled rejection; repaint still runs so cleared harness UI heals.
|
|
66
|
+
void pumpLoops()
|
|
67
|
+
.catch(() => {})
|
|
68
|
+
.then(() => widget.update())
|
|
69
|
+
.catch(() => {});
|
|
67
70
|
}, HEARTBEAT_MS);
|
|
68
71
|
heartbeatTimer.unref?.();
|
|
69
72
|
}
|
|
@@ -92,7 +95,6 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
|
|
|
92
95
|
getStore().expireEventLoops(sessionStartedAt);
|
|
93
96
|
getTriggerSystem().start();
|
|
94
97
|
ensureHeartbeat();
|
|
95
|
-
widget.update();
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -110,6 +112,16 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
|
|
|
110
112
|
getScheduler().pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
116
|
+
setLatestCtx(ctx);
|
|
117
|
+
setSessionId(ctx.sessionManager.getSessionId());
|
|
118
|
+
widget.setUICtx(ctx.ui);
|
|
119
|
+
upgradeStoreIfNeeded(ctx);
|
|
120
|
+
ensureHeartbeat();
|
|
121
|
+
showPersistedLoops();
|
|
122
|
+
widget.update();
|
|
123
|
+
});
|
|
124
|
+
|
|
113
125
|
pi.on("turn_start", async (_event, ctx) => {
|
|
114
126
|
setLatestCtx(ctx);
|
|
115
127
|
setSessionId(ctx.sessionManager.getSessionId());
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./loop-events.js";
|
|
18
18
|
|
|
19
19
|
export const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
20
|
-
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain,
|
|
20
|
+
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, and complete it. If no pending tasks remain, report that and end this iteration; pi-loop manages the worker lifecycle automatically.";
|
|
21
21
|
|
|
22
22
|
export interface TaskBacklogRuntimeOptions {
|
|
23
23
|
getLoops: () => LoopEntry[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { TaskEntry, TaskStatus } from "../task-types.js";
|
|
2
|
+
import type { TaskEntry, TaskStatus, TaskWorkflowLink } from "../task-types.js";
|
|
3
3
|
|
|
4
4
|
export type NativeTaskEventName =
|
|
5
5
|
| "tasks:created"
|
|
@@ -19,6 +19,7 @@ export interface NativeTaskEventPayload {
|
|
|
19
19
|
updatedAt: number;
|
|
20
20
|
completedAt?: number;
|
|
21
21
|
metadata?: Record<string, unknown>;
|
|
22
|
+
workflow?: TaskWorkflowLink;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function emitNativeTaskEvent(
|
|
@@ -37,5 +38,6 @@ export function emitNativeTaskEvent(
|
|
|
37
38
|
updatedAt: entry.updatedAt,
|
|
38
39
|
completedAt: entry.completedAt,
|
|
39
40
|
metadata: entry.metadata,
|
|
41
|
+
workflow: entry.workflow,
|
|
40
42
|
} satisfies NativeTaskEventPayload);
|
|
41
43
|
}
|
package/src/runtime/task-rpc.ts
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
type PingReply,
|
|
8
8
|
replyChannel,
|
|
9
9
|
TASKS_RPC,
|
|
10
|
+
type UpdateTaskReply,
|
|
10
11
|
} from "../rpc/channels.js";
|
|
11
12
|
import { type RpcReply, rpcCall } from "../rpc/cross-extension-rpc.js";
|
|
12
13
|
import type { TaskStore } from "../task-store.js";
|
|
14
|
+
import type { TaskWorkflowLink } from "../task-types.js";
|
|
13
15
|
import type { LoopEntry } from "../types.js";
|
|
14
16
|
import { NATIVE_TASKS_PROVIDER } from "./native-task-rpc.js";
|
|
15
17
|
import { emitNativeTaskEvent } from "./task-events.js";
|
|
@@ -20,7 +22,9 @@ export interface TaskRuntimeBridgeOptions {
|
|
|
20
22
|
setTasksAvailable: (available: boolean) => void;
|
|
21
23
|
getNativeTaskStore: () => TaskStore | undefined;
|
|
22
24
|
onNativeTaskCreated?: (taskStore: TaskStore) => void;
|
|
25
|
+
onNativeTaskCompleted?: (taskStore: TaskStore) => Promise<void> | void;
|
|
23
26
|
onNativeTasksPruned?: (taskStore: TaskStore) => Promise<void> | void;
|
|
27
|
+
isDetectionSettled?: () => boolean;
|
|
24
28
|
/** Called when a detection window opens. */
|
|
25
29
|
onDetectionStarted?: () => void;
|
|
26
30
|
/** Called when a detection window closes (provider found or probe timed out). */
|
|
@@ -31,6 +35,8 @@ export interface TaskRuntimeBridgeOptions {
|
|
|
31
35
|
export interface TaskRuntimeBridge {
|
|
32
36
|
checkTasksVersion(): void;
|
|
33
37
|
autoCreateTask(entry: LoopEntry): Promise<string | undefined>;
|
|
38
|
+
createWorkflowTask(entry: LoopEntry): Promise<string | undefined>;
|
|
39
|
+
completeWorkflowTask(taskId: string): Promise<boolean>;
|
|
34
40
|
hasPendingTasks(): Promise<number>;
|
|
35
41
|
cleanDoneTasks(): Promise<void>;
|
|
36
42
|
}
|
|
@@ -42,7 +48,9 @@ export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): Task
|
|
|
42
48
|
setTasksAvailable,
|
|
43
49
|
getNativeTaskStore,
|
|
44
50
|
onNativeTaskCreated,
|
|
51
|
+
onNativeTaskCompleted,
|
|
45
52
|
onNativeTasksPruned,
|
|
53
|
+
isDetectionSettled,
|
|
46
54
|
onDetectionStarted,
|
|
47
55
|
onDetectionSettled,
|
|
48
56
|
debug,
|
|
@@ -105,6 +113,62 @@ export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): Task
|
|
|
105
113
|
return task.id;
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
async function createWorkflowTask(entry: LoopEntry): Promise<string | undefined> {
|
|
117
|
+
const workflow = entry.workflow;
|
|
118
|
+
if (!workflow) return undefined;
|
|
119
|
+
const state = workflow.definition.states[workflow.currentState];
|
|
120
|
+
if (!state?.task || state.terminal) return undefined;
|
|
121
|
+
|
|
122
|
+
const link: TaskWorkflowLink = {
|
|
123
|
+
loopId: entry.id,
|
|
124
|
+
stateId: workflow.currentState,
|
|
125
|
+
transitionSeq: workflow.transitionSeq,
|
|
126
|
+
};
|
|
127
|
+
const metadata = { workflow: link };
|
|
128
|
+
if (isTasksAvailable()) {
|
|
129
|
+
try {
|
|
130
|
+
const reply = await rpcCall<CreateTaskReply>(pi.events, TASKS_RPC.create, {
|
|
131
|
+
subject: state.task.subject,
|
|
132
|
+
description: state.task.description,
|
|
133
|
+
metadata,
|
|
134
|
+
}, 5000);
|
|
135
|
+
return reply.id;
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isDetectionSettled && !isDetectionSettled()) return undefined;
|
|
142
|
+
const nativeTaskStore = getNativeTaskStore();
|
|
143
|
+
if (!nativeTaskStore) return undefined;
|
|
144
|
+
const task = nativeTaskStore.create(state.task.subject, state.task.description, metadata, link);
|
|
145
|
+
emitNativeTaskEvent(pi, "tasks:created", task);
|
|
146
|
+
onNativeTaskCreated?.(nativeTaskStore);
|
|
147
|
+
return task.id;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function completeWorkflowTask(taskId: string): Promise<boolean> {
|
|
151
|
+
if (isTasksAvailable()) {
|
|
152
|
+
try {
|
|
153
|
+
await rpcCall<UpdateTaskReply>(pi.events, TASKS_RPC.update, { id: taskId, status: "completed" }, 5000);
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const nativeTaskStore = getNativeTaskStore();
|
|
161
|
+
const existing = nativeTaskStore?.get(taskId);
|
|
162
|
+
if (!nativeTaskStore || !existing) return false;
|
|
163
|
+
if (existing.status === "completed") return true;
|
|
164
|
+
|
|
165
|
+
const completed = nativeTaskStore.complete(taskId);
|
|
166
|
+
if (!completed) return false;
|
|
167
|
+
emitNativeTaskEvent(pi, "tasks:completed", completed, existing.status);
|
|
168
|
+
await onNativeTaskCompleted?.(nativeTaskStore);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
108
172
|
async function hasPendingTasks(): Promise<number> {
|
|
109
173
|
if (isTasksAvailable()) {
|
|
110
174
|
// -1 is this bridge's "unknown" sentinel, consumed by notification-runtime;
|
|
@@ -140,6 +204,8 @@ export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): Task
|
|
|
140
204
|
return {
|
|
141
205
|
checkTasksVersion,
|
|
142
206
|
autoCreateTask,
|
|
207
|
+
createWorkflowTask,
|
|
208
|
+
completeWorkflowTask,
|
|
143
209
|
hasPendingTasks,
|
|
144
210
|
cleanDoneTasks,
|
|
145
211
|
};
|
package/src/store.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
|
|
4
4
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
|
-
import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
5
|
+
import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger, WorkflowDefinition, WorkflowTerminalStatus } from "./types.js";
|
|
6
|
+
import { transitionWorkflowRun, validateWorkflowDefinition, type WorkflowTransitionInput } from "./workflow-reducer.js";
|
|
6
7
|
|
|
7
8
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
8
9
|
const MAX_LOOPS = 25;
|
|
@@ -25,11 +26,16 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number; dynamic?: Partial<DynamicLoopState
|
|
29
|
+
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number; dynamic?: Partial<DynamicLoopState>; workflow?: WorkflowDefinition }): LoopEntry {
|
|
29
30
|
return this.withLock(() => {
|
|
30
31
|
if (this.entries.size >= MAX_LOOPS) {
|
|
31
32
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
32
33
|
}
|
|
34
|
+
if (opts.workflow) {
|
|
35
|
+
if (trigger.type !== "dynamic") throw new Error("Workflow loops require a dynamic trigger.");
|
|
36
|
+
const validationError = validateWorkflowDefinition(opts.workflow);
|
|
37
|
+
if (validationError) throw new Error(`Invalid workflow: ${validationError}`);
|
|
38
|
+
}
|
|
33
39
|
const now = Date.now();
|
|
34
40
|
this.applyReducerEvent({
|
|
35
41
|
type: "LOOP_CREATED",
|
|
@@ -45,6 +51,7 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
45
51
|
readOnly: opts.readOnly,
|
|
46
52
|
maxFires: opts.maxFires,
|
|
47
53
|
dynamic: opts.dynamic,
|
|
54
|
+
workflow: opts.workflow,
|
|
48
55
|
},
|
|
49
56
|
});
|
|
50
57
|
return this.entries.get(String(this.nextId - 1))!;
|
|
@@ -154,6 +161,48 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
154
161
|
});
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
transitionWorkflow(id: string, input: WorkflowTransitionInput): { entry?: LoopEntry; applied: boolean; error?: string; terminal?: WorkflowTerminalStatus } {
|
|
165
|
+
return this.withLock(() => {
|
|
166
|
+
const entry = this.entries.get(id);
|
|
167
|
+
if (!entry) return { applied: false, error: `Loop #${id} not found` };
|
|
168
|
+
if (!entry.workflow) return { applied: false, error: `Loop #${id} is not a workflow loop` };
|
|
169
|
+
|
|
170
|
+
const result = transitionWorkflowRun(entry.workflow, input, Date.now());
|
|
171
|
+
if (!result.applied) return { applied: false, error: result.error };
|
|
172
|
+
|
|
173
|
+
this.applyReducerEvent({
|
|
174
|
+
type: "LOOP_WORKFLOW_TRANSITION",
|
|
175
|
+
at: result.run.stateEnteredAt,
|
|
176
|
+
source: "tool",
|
|
177
|
+
entityType: "loop",
|
|
178
|
+
entityId: id,
|
|
179
|
+
payload: {
|
|
180
|
+
id,
|
|
181
|
+
outcome: input.outcome,
|
|
182
|
+
evidence: input.evidence,
|
|
183
|
+
activeTaskId: input.activeTaskId,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
return { entry: this.entries.get(id), applied: true, terminal: result.terminal };
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
setWorkflowActiveTask(id: string, taskId?: string): LoopEntry | undefined {
|
|
191
|
+
return this.withLock(() => {
|
|
192
|
+
const entry = this.entries.get(id);
|
|
193
|
+
if (!entry?.workflow) return undefined;
|
|
194
|
+
this.applyReducerEvent({
|
|
195
|
+
type: "LOOP_WORKFLOW_TASK_SET",
|
|
196
|
+
at: Date.now(),
|
|
197
|
+
source: "tool",
|
|
198
|
+
entityType: "loop",
|
|
199
|
+
entityId: id,
|
|
200
|
+
payload: { id, taskId },
|
|
201
|
+
});
|
|
202
|
+
return this.entries.get(id);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
157
206
|
getDeletionTombstone(id: string): LoopDeletionTombstone | undefined {
|
|
158
207
|
const tombstone = this.tombstones.get(id);
|
|
159
208
|
if (!tombstone) return undefined;
|
package/src/task-reducer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TaskEntry } from "./task-types.js";
|
|
1
|
+
import type { TaskEntry, TaskWorkflowLink } from "./task-types.js";
|
|
2
2
|
|
|
3
3
|
export interface TaskReducerState {
|
|
4
4
|
nextId: number;
|
|
@@ -16,6 +16,7 @@ export type TaskReducerEvent =
|
|
|
16
16
|
subject: string;
|
|
17
17
|
description: string;
|
|
18
18
|
metadata?: Record<string, unknown>;
|
|
19
|
+
workflow?: TaskWorkflowLink;
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
| {
|
|
@@ -87,6 +88,7 @@ export function reduceTaskState(state: TaskReducerState, event: TaskReducerEvent
|
|
|
87
88
|
createdAt: event.at,
|
|
88
89
|
updatedAt: event.at,
|
|
89
90
|
metadata: event.payload.metadata,
|
|
91
|
+
workflow: event.payload.workflow,
|
|
90
92
|
};
|
|
91
93
|
next.tasksById[id] = task;
|
|
92
94
|
return {
|
package/src/task-store.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
4
4
|
import { reduceTaskState, type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
|
|
5
|
-
import type { TaskEntry, TaskStoreData } from "./task-types.js";
|
|
5
|
+
import type { TaskEntry, TaskStoreData, TaskWorkflowLink } from "./task-types.js";
|
|
6
6
|
|
|
7
7
|
const TASKS_DIR = join(homedir(), ".pi", "tasks");
|
|
8
8
|
const MAX_TASKS = 200;
|
|
@@ -22,7 +22,7 @@ export class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, T
|
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
create(subject: string, description: string, metadata?: Record<string, unknown
|
|
25
|
+
create(subject: string, description: string, metadata?: Record<string, unknown>, workflow?: TaskWorkflowLink): TaskEntry {
|
|
26
26
|
return this.withLock(() => {
|
|
27
27
|
if (this.entries.size >= MAX_TASKS) {
|
|
28
28
|
throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
|
|
@@ -33,7 +33,7 @@ export class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, T
|
|
|
33
33
|
at: now,
|
|
34
34
|
source: "tool",
|
|
35
35
|
entityType: "task",
|
|
36
|
-
payload: { subject, description, metadata },
|
|
36
|
+
payload: { subject, description, metadata, workflow },
|
|
37
37
|
});
|
|
38
38
|
return this.entries.get(String(this.nextId - 1))!;
|
|
39
39
|
});
|
package/src/task-types.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export type TaskStatus = "pending" | "in_progress" | "completed";
|
|
2
2
|
|
|
3
|
+
export interface TaskWorkflowLink {
|
|
4
|
+
loopId: string;
|
|
5
|
+
stateId: string;
|
|
6
|
+
transitionSeq: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
export interface TaskEntry {
|
|
4
10
|
id: string;
|
|
5
11
|
subject: string;
|
|
@@ -9,6 +15,7 @@ export interface TaskEntry {
|
|
|
9
15
|
updatedAt: number;
|
|
10
16
|
completedAt?: number;
|
|
11
17
|
metadata?: Record<string, unknown>;
|
|
18
|
+
workflow?: TaskWorkflowLink;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
export interface TaskStoreData {
|