@osolmaz/pi-workflows 0.2.0 → 0.4.0
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 +72 -5
- package/dist/builtins/catalog.d.ts +2 -0
- package/dist/builtins/catalog.js +22 -0
- package/dist/builtins/catalog.js.map +1 -0
- package/dist/builtins/monitor.workflow.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +250 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +235 -0
- package/dist/controllers/sqlite.js +1087 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +95 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.d.ts +3 -0
- package/dist/extension/executor.js +12 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1118 -106
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +6 -0
- package/dist/extension/recorder.js +20 -8
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +50 -0
- package/dist/host/runner.js +379 -0
- package/dist/host/runner.js.map +1 -0
- package/dist/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/catalog.d.ts +43 -0
- package/dist/workflows/catalog.js +79 -0
- package/dist/workflows/catalog.js.map +1 -0
- package/dist/workflows/engine.d.ts +37 -2
- package/dist/workflows/engine.js +297 -29
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/index.d.ts +2 -2
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +19 -15
- package/dist/workflows/loader.js +63 -19
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +41 -0
- package/dist/workflows/migrate-sources.js +129 -0
- package/dist/workflows/migrate-sources.js.map +1 -0
- package/dist/workflows/schema.js +3 -2
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +50 -6
- package/dist/workflows/store.js +448 -53
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +27 -3
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +14 -9
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
- package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
- package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
- package/docs/run-bundles.md +85 -28
- package/docs/workflows.md +161 -17
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +10 -2
- package/src/builtins/catalog.ts +22 -0
- package/src/builtins/monitor.workflow.ts +302 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1545 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +148 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +13 -2
- package/src/extension/index.ts +1325 -130
- package/src/extension/recorder.ts +65 -36
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +445 -0
- package/src/viewer/cli.ts +167 -21
- package/src/workflows/catalog.ts +135 -0
- package/src/workflows/engine.ts +404 -41
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/index.ts +2 -0
- package/src/workflows/loader.ts +76 -22
- package/src/workflows/migrate-sources.ts +167 -0
- package/src/workflows/schema.ts +3 -2
- package/src/workflows/store.ts +557 -48
- package/src/workflows/types.ts +22 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
conditionFalse,
|
|
3
|
+
conditionTrue,
|
|
4
|
+
conditionUnknown,
|
|
5
|
+
defineController,
|
|
6
|
+
type ChildWorkflowRecord,
|
|
7
|
+
} from "@osolmaz/pi-workflows/controllers";
|
|
8
|
+
|
|
9
|
+
type PullRequestSpec = {
|
|
10
|
+
apiBaseUrl: string;
|
|
11
|
+
repository: string;
|
|
12
|
+
number: number;
|
|
13
|
+
expectedHeadSha: string;
|
|
14
|
+
repairWorkflow: string;
|
|
15
|
+
mergeApproved: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type PullRequestStatus = {
|
|
19
|
+
phase: "observing" | "repairing" | "waiting" | "ready" | "merged" | "blocked";
|
|
20
|
+
observedHeadSha?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type PullRequest = {
|
|
24
|
+
merged: boolean;
|
|
25
|
+
merge_commit_sha?: string | null;
|
|
26
|
+
head: { sha: string };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type CommitStatus = {
|
|
30
|
+
state: "error" | "failure" | "pending" | "success";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default defineController<PullRequestSpec, PullRequestStatus>({
|
|
34
|
+
name: "pull-request",
|
|
35
|
+
initialStatus: () => ({ phase: "observing" }),
|
|
36
|
+
|
|
37
|
+
async reconcile(ctx, resource) {
|
|
38
|
+
const client = new GitHubClient(resource.spec.apiBaseUrl, resource.spec.repository);
|
|
39
|
+
const pullRequest = await client.pullRequest(resource.spec.number, ctx.signal);
|
|
40
|
+
const observed = { observedHeadSha: pullRequest.head.sha };
|
|
41
|
+
|
|
42
|
+
if (pullRequest.merged) {
|
|
43
|
+
return ctx.settled({
|
|
44
|
+
controllerStatus: { phase: "merged", ...observed },
|
|
45
|
+
conditions: [conditionTrue("Ready", "Merged")],
|
|
46
|
+
workflowRun: null,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (pullRequest.head.sha !== resource.spec.expectedHeadSha) {
|
|
50
|
+
return ctx.settled({
|
|
51
|
+
controllerStatus: { phase: "blocked", ...observed },
|
|
52
|
+
conditions: [conditionFalse("Ready", "HeadChanged")],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const child = await ctx.workflows.ensure({
|
|
57
|
+
requestKey: `repair:${resource.metadata.generation}:${pullRequest.head.sha}`,
|
|
58
|
+
workflow: resource.spec.repairWorkflow,
|
|
59
|
+
input: {
|
|
60
|
+
repository: resource.spec.repository,
|
|
61
|
+
number: resource.spec.number,
|
|
62
|
+
expectedHeadSha: pullRequest.head.sha,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const workflowRun = workflowReference(child);
|
|
66
|
+
if (child.state === "failed") {
|
|
67
|
+
return ctx.settled({
|
|
68
|
+
controllerStatus: { phase: "blocked", ...observed },
|
|
69
|
+
conditions: [conditionFalse("Ready", "RepairFailed", child.error)],
|
|
70
|
+
workflowRun,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (child.state !== "succeeded") {
|
|
74
|
+
return ctx.requeueAfter(5_000, {
|
|
75
|
+
controllerStatus: { phase: "repairing", ...observed },
|
|
76
|
+
conditions: [conditionUnknown("Ready", "RepairRunning")],
|
|
77
|
+
workflowRun,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const checks = await client.commitStatus(pullRequest.head.sha, ctx.signal);
|
|
82
|
+
if (checks.state !== "success") {
|
|
83
|
+
return ctx.requeueAfter(30_000, {
|
|
84
|
+
controllerStatus: { phase: "waiting", ...observed },
|
|
85
|
+
conditions: [conditionUnknown("Ready", "ChecksPending", checks.state)],
|
|
86
|
+
workflowRun,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (!resource.spec.mergeApproved) {
|
|
90
|
+
return ctx.settled({
|
|
91
|
+
controllerStatus: { phase: "ready", ...observed },
|
|
92
|
+
conditions: [conditionFalse("Ready", "ApprovalRequired")],
|
|
93
|
+
workflowRun,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const effect = await ctx.effects.ensure({
|
|
98
|
+
key: `merge:${resource.metadata.generation}:${pullRequest.head.sha}`,
|
|
99
|
+
kind: "github-merge",
|
|
100
|
+
request: {
|
|
101
|
+
repository: resource.spec.repository,
|
|
102
|
+
number: resource.spec.number,
|
|
103
|
+
expectedHeadSha: pullRequest.head.sha,
|
|
104
|
+
},
|
|
105
|
+
observe: async (signal) => {
|
|
106
|
+
const latest = await client.pullRequest(resource.spec.number, signal);
|
|
107
|
+
return latest.merged
|
|
108
|
+
? {
|
|
109
|
+
state: "applied",
|
|
110
|
+
...(latest.merge_commit_sha ? { externalRef: latest.merge_commit_sha } : {}),
|
|
111
|
+
}
|
|
112
|
+
: { state: "not_applied" };
|
|
113
|
+
},
|
|
114
|
+
apply: async (signal) =>
|
|
115
|
+
await client.merge(resource.spec.number, pullRequest.head.sha, signal),
|
|
116
|
+
});
|
|
117
|
+
if (effect.state === "rejected") {
|
|
118
|
+
return ctx.settled({
|
|
119
|
+
controllerStatus: { phase: "blocked", ...observed },
|
|
120
|
+
conditions: [conditionFalse("Ready", "MergeRejected", effect.error)],
|
|
121
|
+
workflowRun,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (effect.state === "indeterminate") {
|
|
125
|
+
return ctx.requeueAfter(10_000, {
|
|
126
|
+
controllerStatus: { phase: "waiting", ...observed },
|
|
127
|
+
conditions: [conditionUnknown("Ready", "MergeUncertain", effect.error)],
|
|
128
|
+
workflowRun,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return ctx.requeue({
|
|
132
|
+
controllerStatus: { phase: "waiting", ...observed },
|
|
133
|
+
conditions: [conditionUnknown("Ready", "MergeSubmitted")],
|
|
134
|
+
workflowRun,
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
class GitHubClient {
|
|
140
|
+
private readonly baseUrl: URL;
|
|
141
|
+
private readonly owner: string;
|
|
142
|
+
private readonly repository: string;
|
|
143
|
+
|
|
144
|
+
constructor(baseUrl: string, repository: string) {
|
|
145
|
+
this.baseUrl = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
|
|
146
|
+
const parts = repository.split("/");
|
|
147
|
+
if (parts.length !== 2 || parts.some((part) => part.length === 0)) {
|
|
148
|
+
throw new Error(`Invalid repository: ${repository}`);
|
|
149
|
+
}
|
|
150
|
+
this.owner = parts[0] as string;
|
|
151
|
+
this.repository = parts[1] as string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async pullRequest(number: number, signal: AbortSignal): Promise<PullRequest> {
|
|
155
|
+
return await this.request<PullRequest>(`pulls/${number}`, { signal });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async commitStatus(sha: string, signal: AbortSignal): Promise<CommitStatus> {
|
|
159
|
+
return await this.request<CommitStatus>(`commits/${encodeURIComponent(sha)}/status`, {
|
|
160
|
+
signal,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async merge(number: number, headSha: string, signal: AbortSignal) {
|
|
165
|
+
const response = await this.rawRequest(`pulls/${number}/merge`, {
|
|
166
|
+
method: "PUT",
|
|
167
|
+
signal,
|
|
168
|
+
headers: { "content-type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ sha: headSha }),
|
|
170
|
+
});
|
|
171
|
+
const body = (await response.json()) as {
|
|
172
|
+
merged?: boolean;
|
|
173
|
+
message?: string;
|
|
174
|
+
sha?: string;
|
|
175
|
+
};
|
|
176
|
+
if (!response.ok || body.merged !== true) {
|
|
177
|
+
return { state: "rejected" as const, error: body.message ?? `HTTP ${response.status}` };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
state: "applied" as const,
|
|
181
|
+
...(body.sha !== undefined ? { externalRef: body.sha } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async request<T>(path: string, init: RequestInit): Promise<T> {
|
|
186
|
+
const response = await this.rawRequest(path, init);
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new Error(`GitHub request failed with HTTP ${response.status}`);
|
|
189
|
+
}
|
|
190
|
+
return (await response.json()) as T;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async rawRequest(path: string, init: RequestInit): Promise<Response> {
|
|
194
|
+
const url = new URL(
|
|
195
|
+
`repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repository)}/${path}`,
|
|
196
|
+
this.baseUrl,
|
|
197
|
+
);
|
|
198
|
+
return await fetch(url, {
|
|
199
|
+
...init,
|
|
200
|
+
headers: {
|
|
201
|
+
accept: "application/vnd.github+json",
|
|
202
|
+
...init.headers,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function workflowReference(child: ChildWorkflowRecord) {
|
|
209
|
+
return {
|
|
210
|
+
requestId: child.requestId,
|
|
211
|
+
...(child.runId !== undefined ? { runId: child.runId } : {}),
|
|
212
|
+
state: child.state,
|
|
213
|
+
attempt: child.attempt,
|
|
214
|
+
};
|
|
215
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osolmaz/pi-workflows",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Workflow
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Workflow and controller runtime with a live terminal viewer for the pi coding agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
7
7
|
],
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"./extension": {
|
|
35
35
|
"types": "./dist/extension/index.d.ts",
|
|
36
36
|
"default": "./dist/extension/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./controllers": {
|
|
39
|
+
"types": "./dist/controllers/index.d.ts",
|
|
40
|
+
"default": "./dist/controllers/index.js"
|
|
37
41
|
}
|
|
38
42
|
},
|
|
39
43
|
"publishConfig": {
|
|
@@ -53,10 +57,13 @@
|
|
|
53
57
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
54
58
|
},
|
|
55
59
|
"dependencies": {
|
|
60
|
+
"better-sqlite3": "^13.0.2",
|
|
56
61
|
"jiti": "^2.7.0"
|
|
57
62
|
},
|
|
58
63
|
"devDependencies": {
|
|
64
|
+
"@earendil-works/pi-ai": "^0.80.10",
|
|
59
65
|
"@earendil-works/pi-coding-agent": "^0.80.10",
|
|
66
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
60
67
|
"@types/node": "^26.1.1",
|
|
61
68
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
62
69
|
"oxfmt": "^0.59.0",
|
|
@@ -67,6 +74,7 @@
|
|
|
67
74
|
"vitest": "^4.1.10"
|
|
68
75
|
},
|
|
69
76
|
"peerDependencies": {
|
|
77
|
+
"@earendil-works/pi-ai": "*",
|
|
70
78
|
"@earendil-works/pi-coding-agent": "*",
|
|
71
79
|
"typebox": "*"
|
|
72
80
|
},
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { BuiltinWorkflowCatalog } from "../workflows/catalog.js";
|
|
2
|
+
import monitorWorkflow from "./monitor.workflow.js";
|
|
3
|
+
|
|
4
|
+
export const builtinWorkflowCatalog = new BuiltinWorkflowCatalog([
|
|
5
|
+
{
|
|
6
|
+
id: "monitor",
|
|
7
|
+
revision: "1",
|
|
8
|
+
definition: monitorWorkflow,
|
|
9
|
+
legacySources: [
|
|
10
|
+
{
|
|
11
|
+
workflowHash: "7a22158da94d18ec1c9fe42e70d72017a4e0620d5e5142ae839d0cd6eea55c06",
|
|
12
|
+
revision: "1",
|
|
13
|
+
pathSuffixes: [
|
|
14
|
+
"/src/builtins/monitor.workflow.ts",
|
|
15
|
+
"/dist/builtins/monitor.workflow.js",
|
|
16
|
+
"/src/workflows/monitor.workflow.ts",
|
|
17
|
+
"/dist/workflows/monitor.workflow.js",
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
]);
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { agent, compute, defineWorkflow, shell } from "../workflows/definition.js";
|
|
2
|
+
import type { WorkflowNodeContext } from "../workflows/types.js";
|
|
3
|
+
|
|
4
|
+
const MIN_INTERVAL_MINUTES = 1;
|
|
5
|
+
const MAX_INTERVAL_MINUTES = 24 * 60;
|
|
6
|
+
const MIN_CHECK_TIMEOUT_MINUTES = 5;
|
|
7
|
+
const MAX_CHECK_TIMEOUT_MINUTES = 24 * 60;
|
|
8
|
+
const DEFAULT_MIN_CHECK_TIMEOUT_MINUTES = 60;
|
|
9
|
+
const DEFAULT_MAX_CHECKS = 1_000;
|
|
10
|
+
const MAX_CHECKS = 1_000;
|
|
11
|
+
const MAX_OBSERVATION_CHARS = 8_000;
|
|
12
|
+
const MAX_REPORT_CHARS = 4_000;
|
|
13
|
+
const MAX_REASON_CHARS = 2_000;
|
|
14
|
+
const SLEEP_TIMEOUT_MARGIN_MS = 60_000;
|
|
15
|
+
const NODE_TIMEOUT_MARGIN_MS = 2 * 60_000;
|
|
16
|
+
|
|
17
|
+
type MonitorInput = {
|
|
18
|
+
task: string;
|
|
19
|
+
everyMinutes: number;
|
|
20
|
+
reportWhen?: string;
|
|
21
|
+
stopWhen?: string;
|
|
22
|
+
maxChecks?: number;
|
|
23
|
+
checkTimeoutMinutes?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type MonitorConfig = {
|
|
27
|
+
task: string;
|
|
28
|
+
everyMinutes: number;
|
|
29
|
+
reportWhen: string;
|
|
30
|
+
stopWhen: string;
|
|
31
|
+
maxChecks: number;
|
|
32
|
+
checkTimeoutMinutes: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type MonitorRoute = "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report";
|
|
36
|
+
|
|
37
|
+
type MonitorCheck = {
|
|
38
|
+
route: MonitorRoute;
|
|
39
|
+
observation: string;
|
|
40
|
+
report?: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const MONITOR_ROUTES = new Set<MonitorRoute>([
|
|
45
|
+
"continue_quiet",
|
|
46
|
+
"continue_report",
|
|
47
|
+
"stop_quiet",
|
|
48
|
+
"stop_report",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
52
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
53
|
+
throw new Error(`${label} must be an object`);
|
|
54
|
+
}
|
|
55
|
+
return value as Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requireBoundedString(value: unknown, label: string, maxChars: number): string {
|
|
59
|
+
if (typeof value !== "string") {
|
|
60
|
+
throw new Error(`${label} must be a string`);
|
|
61
|
+
}
|
|
62
|
+
const trimmed = value.trim();
|
|
63
|
+
if (trimmed.length === 0) {
|
|
64
|
+
throw new Error(`${label} must not be empty`);
|
|
65
|
+
}
|
|
66
|
+
if (trimmed.length > maxChars) {
|
|
67
|
+
throw new Error(`${label} must be at most ${maxChars} characters`);
|
|
68
|
+
}
|
|
69
|
+
return trimmed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function prepareInput(input: unknown): MonitorConfig {
|
|
73
|
+
const value = requireRecord(input, "monitor input") as Partial<MonitorInput>;
|
|
74
|
+
const task = requireBoundedString(value.task, "task", 8_000);
|
|
75
|
+
if (
|
|
76
|
+
typeof value.everyMinutes !== "number" ||
|
|
77
|
+
!Number.isInteger(value.everyMinutes) ||
|
|
78
|
+
value.everyMinutes < MIN_INTERVAL_MINUTES ||
|
|
79
|
+
value.everyMinutes > MAX_INTERVAL_MINUTES
|
|
80
|
+
) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`everyMinutes must be an integer from ${MIN_INTERVAL_MINUTES} through ${MAX_INTERVAL_MINUTES}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const maxChecks = value.maxChecks ?? DEFAULT_MAX_CHECKS;
|
|
86
|
+
if (!Number.isInteger(maxChecks) || maxChecks <= 0 || maxChecks > MAX_CHECKS) {
|
|
87
|
+
throw new Error(`maxChecks must be an integer from 1 through ${MAX_CHECKS}`);
|
|
88
|
+
}
|
|
89
|
+
const checkTimeoutMinutes =
|
|
90
|
+
value.checkTimeoutMinutes ?? Math.max(DEFAULT_MIN_CHECK_TIMEOUT_MINUTES, value.everyMinutes);
|
|
91
|
+
if (
|
|
92
|
+
!Number.isInteger(checkTimeoutMinutes) ||
|
|
93
|
+
checkTimeoutMinutes < MIN_CHECK_TIMEOUT_MINUTES ||
|
|
94
|
+
checkTimeoutMinutes > MAX_CHECK_TIMEOUT_MINUTES
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`checkTimeoutMinutes must be an integer from ${MIN_CHECK_TIMEOUT_MINUTES} through ${MAX_CHECK_TIMEOUT_MINUTES}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
task,
|
|
102
|
+
everyMinutes: value.everyMinutes,
|
|
103
|
+
reportWhen:
|
|
104
|
+
value.reportWhen === undefined
|
|
105
|
+
? "The observed state changes materially or needs the user's attention."
|
|
106
|
+
: requireBoundedString(value.reportWhen, "reportWhen", 4_000),
|
|
107
|
+
stopWhen:
|
|
108
|
+
value.stopWhen === undefined
|
|
109
|
+
? "The user cancels the monitor or it reaches its maximum check count."
|
|
110
|
+
: requireBoundedString(value.stopWhen, "stopWhen", 4_000),
|
|
111
|
+
maxChecks,
|
|
112
|
+
checkTimeoutMinutes,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function configFrom(outputs: Record<string, unknown>): MonitorConfig {
|
|
117
|
+
return outputs.prepare as MonitorConfig;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function agentTimeoutMs({ outputs }: WorkflowNodeContext): number {
|
|
121
|
+
return configFrom(outputs).checkTimeoutMinutes * 60_000;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function completedChecks(context: WorkflowNodeContext): number {
|
|
125
|
+
return context.state.steps.filter((step) => step.nodeId === "check" && step.outcome === "ok")
|
|
126
|
+
.length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateCheck(output: unknown): MonitorCheck {
|
|
130
|
+
const value = requireRecord(output, "monitor check output");
|
|
131
|
+
if (typeof value.route !== "string" || !MONITOR_ROUTES.has(value.route as MonitorRoute)) {
|
|
132
|
+
throw new Error(`route must be one of ${[...MONITOR_ROUTES].join(", ")}`);
|
|
133
|
+
}
|
|
134
|
+
const route = value.route as MonitorRoute;
|
|
135
|
+
const observation = requireBoundedString(value.observation, "observation", MAX_OBSERVATION_CHARS);
|
|
136
|
+
const reason = requireBoundedString(value.reason, "reason", MAX_REASON_CHARS);
|
|
137
|
+
const reports = route === "continue_report" || route === "stop_report";
|
|
138
|
+
const report =
|
|
139
|
+
value.report === undefined
|
|
140
|
+
? undefined
|
|
141
|
+
: requireBoundedString(value.report, "report", MAX_REPORT_CHARS);
|
|
142
|
+
if (reports && report === undefined) {
|
|
143
|
+
throw new Error(`route ${route} requires a report`);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
route,
|
|
147
|
+
observation,
|
|
148
|
+
...(report !== undefined ? { report } : {}),
|
|
149
|
+
reason,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function validateReportAck(output: unknown): { reported: true } {
|
|
154
|
+
const value = requireRecord(output, "report acknowledgement");
|
|
155
|
+
if (value.reported !== true) {
|
|
156
|
+
throw new Error("report acknowledgement must set reported to true");
|
|
157
|
+
}
|
|
158
|
+
return { reported: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function reportPrompt(outputs: Record<string, unknown>): string {
|
|
162
|
+
const check = outputs.check as MonitorCheck;
|
|
163
|
+
return [
|
|
164
|
+
"Write one concise normal assistant message to the user with this monitoring update:",
|
|
165
|
+
check.report ?? check.observation,
|
|
166
|
+
"Do not add unrelated detail.",
|
|
167
|
+
"After writing the update, submit the acknowledgement required by the workflow step contract.",
|
|
168
|
+
].join("\n\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export default defineWorkflow({
|
|
172
|
+
name: "monitor",
|
|
173
|
+
title: ({ input }) => {
|
|
174
|
+
try {
|
|
175
|
+
const task = prepareInput(input).task;
|
|
176
|
+
return `monitor: ${task.slice(0, 80)}`;
|
|
177
|
+
} catch {
|
|
178
|
+
return "monitor";
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
presentationPrompt: ({ finalOutput }) => {
|
|
182
|
+
const result = requireRecord(finalOutput, "monitor result");
|
|
183
|
+
if (result.reported === true) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
return `Tell the user concisely why this monitor stopped: ${String(result.reason ?? "monitor ended")}`;
|
|
187
|
+
},
|
|
188
|
+
startAt: "prepare",
|
|
189
|
+
maxSteps: 5_010,
|
|
190
|
+
nodes: {
|
|
191
|
+
prepare: compute({
|
|
192
|
+
run: ({ input }) => prepareInput(input),
|
|
193
|
+
}),
|
|
194
|
+
guard: compute({
|
|
195
|
+
run: (context) => {
|
|
196
|
+
const config = configFrom(context.outputs);
|
|
197
|
+
const checks = completedChecks(context);
|
|
198
|
+
return checks >= config.maxChecks
|
|
199
|
+
? { route: "stop", checks, reason: `Reached the ${config.maxChecks}-check limit.` }
|
|
200
|
+
: { route: "check", checks };
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
continue_guard: compute({
|
|
204
|
+
run: (context) => {
|
|
205
|
+
const config = configFrom(context.outputs);
|
|
206
|
+
const checks = completedChecks(context);
|
|
207
|
+
return checks >= config.maxChecks
|
|
208
|
+
? { route: "stop", checks, reason: `Reached the ${config.maxChecks}-check limit.` }
|
|
209
|
+
: { route: "sleep", checks };
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
check: agent({
|
|
213
|
+
statusDetail: "checking monitored target",
|
|
214
|
+
timeoutMs: agentTimeoutMs,
|
|
215
|
+
prompt: (context) => {
|
|
216
|
+
const config = configFrom(context.outputs);
|
|
217
|
+
const previous = context.outputs.check as MonitorCheck | undefined;
|
|
218
|
+
const checkNumber = completedChecks(context) + 1;
|
|
219
|
+
return [
|
|
220
|
+
`Perform monitoring check ${checkNumber} of at most ${config.maxChecks}.`,
|
|
221
|
+
`Task: ${config.task}`,
|
|
222
|
+
`Report when: ${config.reportWhen}`,
|
|
223
|
+
`Stop when: ${config.stopWhen}`,
|
|
224
|
+
previous === undefined
|
|
225
|
+
? "There is no previous observation. Report the initial state only when the report condition calls for it."
|
|
226
|
+
: `Previous accepted observation: ${previous.observation}`,
|
|
227
|
+
"Use available tools to inspect the current state. Observe only unless the task explicitly authorizes a mutation.",
|
|
228
|
+
"Choose continue_quiet, continue_report, stop_quiet, or stop_report. A report route requires concise report text.",
|
|
229
|
+
].join("\n\n");
|
|
230
|
+
},
|
|
231
|
+
expectedOutput:
|
|
232
|
+
'{ "route": "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report", "observation": "current factual state", "report": "required for report routes", "reason": "short reason" }',
|
|
233
|
+
validate: (output) => validateCheck(output),
|
|
234
|
+
}),
|
|
235
|
+
report_continue: agent({
|
|
236
|
+
statusDetail: "reporting monitor update",
|
|
237
|
+
timeoutMs: agentTimeoutMs,
|
|
238
|
+
prompt: ({ outputs }) => reportPrompt(outputs),
|
|
239
|
+
expectedOutput: '{ "reported": true }',
|
|
240
|
+
validate: (output) => validateReportAck(output),
|
|
241
|
+
}),
|
|
242
|
+
report_stop: agent({
|
|
243
|
+
statusDetail: "reporting final monitor update",
|
|
244
|
+
timeoutMs: agentTimeoutMs,
|
|
245
|
+
prompt: ({ outputs }) => reportPrompt(outputs),
|
|
246
|
+
expectedOutput: '{ "reported": true }',
|
|
247
|
+
validate: (output) => validateReportAck(output),
|
|
248
|
+
}),
|
|
249
|
+
sleep: shell({
|
|
250
|
+
statusDetail: "waiting for next monitor check",
|
|
251
|
+
timeoutMs: MAX_INTERVAL_MINUTES * 60_000 + NODE_TIMEOUT_MARGIN_MS,
|
|
252
|
+
exec: ({ outputs }) => {
|
|
253
|
+
const config = configFrom(outputs);
|
|
254
|
+
const sleepMs = config.everyMinutes * 60_000;
|
|
255
|
+
return {
|
|
256
|
+
command: process.execPath,
|
|
257
|
+
args: ["-e", "setTimeout(() => {}, Number(process.argv[1]))", String(sleepMs)],
|
|
258
|
+
timeoutMs: sleepMs + SLEEP_TIMEOUT_MARGIN_MS,
|
|
259
|
+
maxOutputChars: 1_024,
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
parse: (_result, { outputs }) => ({ waitedMinutes: configFrom(outputs).everyMinutes }),
|
|
263
|
+
}),
|
|
264
|
+
finish: compute({
|
|
265
|
+
run: ({ outputs }) => {
|
|
266
|
+
const check = outputs.check as MonitorCheck | undefined;
|
|
267
|
+
const guard = outputs.guard as { reason?: string } | undefined;
|
|
268
|
+
const continueGuard = outputs.continue_guard as { reason?: string } | undefined;
|
|
269
|
+
return {
|
|
270
|
+
reason: continueGuard?.reason ?? guard?.reason ?? check?.reason ?? "Monitor finished.",
|
|
271
|
+
observation: check?.observation ?? null,
|
|
272
|
+
reported:
|
|
273
|
+
outputs.report_stop !== undefined ||
|
|
274
|
+
(check !== undefined && check.route === "stop_report"),
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
},
|
|
279
|
+
edges: [
|
|
280
|
+
{ from: "prepare", to: "guard" },
|
|
281
|
+
{ from: "guard", switch: { on: "$.route", cases: { check: "check", stop: "finish" } } },
|
|
282
|
+
{
|
|
283
|
+
from: "check",
|
|
284
|
+
switch: {
|
|
285
|
+
on: "$.route",
|
|
286
|
+
cases: {
|
|
287
|
+
continue_quiet: "continue_guard",
|
|
288
|
+
continue_report: "report_continue",
|
|
289
|
+
stop_quiet: "finish",
|
|
290
|
+
stop_report: "report_stop",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
{ from: "report_continue", to: "continue_guard" },
|
|
295
|
+
{ from: "report_stop", to: "finish" },
|
|
296
|
+
{
|
|
297
|
+
from: "continue_guard",
|
|
298
|
+
switch: { on: "$.route", cases: { sleep: "sleep", stop: "finish" } },
|
|
299
|
+
},
|
|
300
|
+
{ from: "sleep", to: "guard" },
|
|
301
|
+
],
|
|
302
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ControllerCondition,
|
|
3
|
+
ControllerConditionInput,
|
|
4
|
+
ControllerConditionStatus,
|
|
5
|
+
ControllerResourceStatus,
|
|
6
|
+
ControllerStatusPatch,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
export function conditionTrue(
|
|
10
|
+
type: string,
|
|
11
|
+
reason: string,
|
|
12
|
+
message?: string,
|
|
13
|
+
): ControllerConditionInput {
|
|
14
|
+
return condition(type, true, reason, message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function conditionFalse(
|
|
18
|
+
type: string,
|
|
19
|
+
reason: string,
|
|
20
|
+
message?: string,
|
|
21
|
+
): ControllerConditionInput {
|
|
22
|
+
return condition(type, false, reason, message);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function conditionUnknown(
|
|
26
|
+
type: string,
|
|
27
|
+
reason: string,
|
|
28
|
+
message?: string,
|
|
29
|
+
): ControllerConditionInput {
|
|
30
|
+
return condition(type, "unknown", reason, message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function applyStatusPatch<TStatus>(
|
|
34
|
+
current: ControllerResourceStatus<TStatus>,
|
|
35
|
+
patch: ControllerStatusPatch<TStatus> | undefined,
|
|
36
|
+
generation: number,
|
|
37
|
+
now: string,
|
|
38
|
+
): ControllerResourceStatus<TStatus> {
|
|
39
|
+
return {
|
|
40
|
+
observedGeneration: generation,
|
|
41
|
+
conditions: mergeConditions(current.conditions, patch?.conditions ?? [], generation, now),
|
|
42
|
+
controllerStatus:
|
|
43
|
+
patch !== undefined && Object.hasOwn(patch, "controllerStatus")
|
|
44
|
+
? (patch.controllerStatus as TStatus)
|
|
45
|
+
: current.controllerStatus,
|
|
46
|
+
...(patch?.workflowRun === null
|
|
47
|
+
? {}
|
|
48
|
+
: patch?.workflowRun !== undefined
|
|
49
|
+
? { workflowRun: patch.workflowRun }
|
|
50
|
+
: current.workflowRun !== undefined
|
|
51
|
+
? { workflowRun: current.workflowRun }
|
|
52
|
+
: {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function mergeConditions(
|
|
57
|
+
current: ControllerCondition[],
|
|
58
|
+
updates: ControllerConditionInput[],
|
|
59
|
+
generation: number,
|
|
60
|
+
now: string,
|
|
61
|
+
): ControllerCondition[] {
|
|
62
|
+
const byType = new Map(current.map((item) => [item.type, item]));
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
for (const update of updates) {
|
|
65
|
+
validateCondition(update);
|
|
66
|
+
if (seen.has(update.type)) {
|
|
67
|
+
throw new Error(`Condition ${JSON.stringify(update.type)} was updated more than once`);
|
|
68
|
+
}
|
|
69
|
+
seen.add(update.type);
|
|
70
|
+
const previous = byType.get(update.type);
|
|
71
|
+
const next: ControllerCondition = {
|
|
72
|
+
...update,
|
|
73
|
+
observedGeneration: generation,
|
|
74
|
+
lastTransitionTime:
|
|
75
|
+
previous !== undefined && previous.status === update.status
|
|
76
|
+
? previous.lastTransitionTime
|
|
77
|
+
: now,
|
|
78
|
+
};
|
|
79
|
+
byType.set(update.type, next);
|
|
80
|
+
}
|
|
81
|
+
return [...byType.values()];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function condition(
|
|
85
|
+
type: string,
|
|
86
|
+
status: ControllerConditionStatus,
|
|
87
|
+
reason: string,
|
|
88
|
+
message: string | undefined,
|
|
89
|
+
): ControllerConditionInput {
|
|
90
|
+
const value: ControllerConditionInput = {
|
|
91
|
+
type,
|
|
92
|
+
status,
|
|
93
|
+
reason,
|
|
94
|
+
...(message !== undefined ? { message } : {}),
|
|
95
|
+
};
|
|
96
|
+
validateCondition(value);
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateCondition(value: ControllerConditionInput): void {
|
|
101
|
+
if (value.type.trim().length === 0) {
|
|
102
|
+
throw new Error("Condition type must not be empty");
|
|
103
|
+
}
|
|
104
|
+
if (value.reason.trim().length === 0) {
|
|
105
|
+
throw new Error("Condition reason must not be empty");
|
|
106
|
+
}
|
|
107
|
+
if (value.status !== true && value.status !== false && value.status !== "unknown") {
|
|
108
|
+
throw new Error(`Invalid condition status for ${JSON.stringify(value.type)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|