@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.
Files changed (152) hide show
  1. package/README.md +72 -5
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +22 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +71 -0
  6. package/dist/builtins/monitor.workflow.js +250 -0
  7. package/dist/builtins/monitor.workflow.js.map +1 -0
  8. package/dist/controllers/conditions.d.ts +6 -0
  9. package/dist/controllers/conditions.js +68 -0
  10. package/dist/controllers/conditions.js.map +1 -0
  11. package/dist/controllers/definition.d.ts +6 -0
  12. package/dist/controllers/definition.js +45 -0
  13. package/dist/controllers/definition.js.map +1 -0
  14. package/dist/controllers/effects.d.ts +14 -0
  15. package/dist/controllers/effects.js +104 -0
  16. package/dist/controllers/effects.js.map +1 -0
  17. package/dist/controllers/errors.d.ts +12 -0
  18. package/dist/controllers/errors.js +25 -0
  19. package/dist/controllers/errors.js.map +1 -0
  20. package/dist/controllers/index.d.ts +13 -0
  21. package/dist/controllers/index.js +13 -0
  22. package/dist/controllers/index.js.map +1 -0
  23. package/dist/controllers/json.d.ts +5 -0
  24. package/dist/controllers/json.js +57 -0
  25. package/dist/controllers/json.js.map +1 -0
  26. package/dist/controllers/loader.d.ts +23 -0
  27. package/dist/controllers/loader.js +74 -0
  28. package/dist/controllers/loader.js.map +1 -0
  29. package/dist/controllers/manager.d.ts +58 -0
  30. package/dist/controllers/manager.js +399 -0
  31. package/dist/controllers/manager.js.map +1 -0
  32. package/dist/controllers/results.d.ts +5 -0
  33. package/dist/controllers/results.js +32 -0
  34. package/dist/controllers/results.js.map +1 -0
  35. package/dist/controllers/sqlite.d.ts +235 -0
  36. package/dist/controllers/sqlite.js +1087 -0
  37. package/dist/controllers/sqlite.js.map +1 -0
  38. package/dist/controllers/store.d.ts +112 -0
  39. package/dist/controllers/store.js +32 -0
  40. package/dist/controllers/store.js.map +1 -0
  41. package/dist/controllers/types.d.ts +159 -0
  42. package/dist/controllers/types.js +2 -0
  43. package/dist/controllers/types.js.map +1 -0
  44. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  45. package/dist/controllers/workflow-engine-scheduler.js +95 -0
  46. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  47. package/dist/controllers/workflows.d.ts +27 -0
  48. package/dist/controllers/workflows.js +109 -0
  49. package/dist/controllers/workflows.js.map +1 -0
  50. package/dist/extension/controller-host.d.ts +47 -0
  51. package/dist/extension/controller-host.js +110 -0
  52. package/dist/extension/controller-host.js.map +1 -0
  53. package/dist/extension/executor.d.ts +3 -0
  54. package/dist/extension/executor.js +12 -2
  55. package/dist/extension/executor.js.map +1 -1
  56. package/dist/extension/index.d.ts +7 -0
  57. package/dist/extension/index.js +1118 -106
  58. package/dist/extension/index.js.map +1 -1
  59. package/dist/extension/recorder.d.ts +6 -0
  60. package/dist/extension/recorder.js +20 -8
  61. package/dist/extension/recorder.js.map +1 -1
  62. package/dist/extension/workflow-tool.d.ts +28 -0
  63. package/dist/extension/workflow-tool.js +33 -0
  64. package/dist/extension/workflow-tool.js.map +1 -0
  65. package/dist/host/processes.d.ts +24 -0
  66. package/dist/host/processes.js +114 -0
  67. package/dist/host/processes.js.map +1 -0
  68. package/dist/host/rpc-bridge.d.ts +9 -0
  69. package/dist/host/rpc-bridge.js +39 -0
  70. package/dist/host/rpc-bridge.js.map +1 -0
  71. package/dist/host/rpc-executor.d.ts +38 -0
  72. package/dist/host/rpc-executor.js +254 -0
  73. package/dist/host/rpc-executor.js.map +1 -0
  74. package/dist/host/runner.d.ts +50 -0
  75. package/dist/host/runner.js +379 -0
  76. package/dist/host/runner.js.map +1 -0
  77. package/dist/viewer/cli.d.ts +7 -3
  78. package/dist/viewer/cli.js +150 -19
  79. package/dist/viewer/cli.js.map +1 -1
  80. package/dist/workflows/catalog.d.ts +43 -0
  81. package/dist/workflows/catalog.js +79 -0
  82. package/dist/workflows/catalog.js.map +1 -0
  83. package/dist/workflows/engine.d.ts +37 -2
  84. package/dist/workflows/engine.js +297 -29
  85. package/dist/workflows/engine.js.map +1 -1
  86. package/dist/workflows/errors.d.ts +23 -0
  87. package/dist/workflows/errors.js +38 -0
  88. package/dist/workflows/errors.js.map +1 -1
  89. package/dist/workflows/graph.js +0 -5
  90. package/dist/workflows/graph.js.map +1 -1
  91. package/dist/workflows/index.d.ts +2 -2
  92. package/dist/workflows/index.js +1 -1
  93. package/dist/workflows/index.js.map +1 -1
  94. package/dist/workflows/loader.d.ts +19 -15
  95. package/dist/workflows/loader.js +63 -19
  96. package/dist/workflows/loader.js.map +1 -1
  97. package/dist/workflows/migrate-sources.d.ts +41 -0
  98. package/dist/workflows/migrate-sources.js +129 -0
  99. package/dist/workflows/migrate-sources.js.map +1 -0
  100. package/dist/workflows/schema.js +3 -2
  101. package/dist/workflows/schema.js.map +1 -1
  102. package/dist/workflows/store.d.ts +50 -6
  103. package/dist/workflows/store.js +448 -53
  104. package/dist/workflows/store.js.map +1 -1
  105. package/dist/workflows/types.d.ts +27 -3
  106. package/docs/CONTROLLERS.md +215 -0
  107. package/docs/development.md +14 -9
  108. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  109. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  110. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  111. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  112. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  113. package/docs/run-bundles.md +85 -28
  114. package/docs/workflows.md +161 -17
  115. package/examples/controllers/pull-request.controller.ts +215 -0
  116. package/package.json +10 -2
  117. package/src/builtins/catalog.ts +22 -0
  118. package/src/builtins/monitor.workflow.ts +302 -0
  119. package/src/controllers/conditions.ts +110 -0
  120. package/src/controllers/definition.ts +65 -0
  121. package/src/controllers/effects.ts +123 -0
  122. package/src/controllers/errors.ts +27 -0
  123. package/src/controllers/index.ts +90 -0
  124. package/src/controllers/json.ts +62 -0
  125. package/src/controllers/loader.ts +104 -0
  126. package/src/controllers/manager.ts +533 -0
  127. package/src/controllers/results.ts +46 -0
  128. package/src/controllers/sqlite.ts +1545 -0
  129. package/src/controllers/store.ts +160 -0
  130. package/src/controllers/types.ts +183 -0
  131. package/src/controllers/workflow-engine-scheduler.ts +148 -0
  132. package/src/controllers/workflows.ts +152 -0
  133. package/src/extension/controller-host.ts +163 -0
  134. package/src/extension/executor.ts +13 -2
  135. package/src/extension/index.ts +1325 -130
  136. package/src/extension/recorder.ts +65 -36
  137. package/src/extension/workflow-tool.ts +59 -0
  138. package/src/host/processes.ts +119 -0
  139. package/src/host/rpc-bridge.ts +44 -0
  140. package/src/host/rpc-executor.ts +299 -0
  141. package/src/host/runner.ts +445 -0
  142. package/src/viewer/cli.ts +167 -21
  143. package/src/workflows/catalog.ts +135 -0
  144. package/src/workflows/engine.ts +404 -41
  145. package/src/workflows/errors.ts +45 -0
  146. package/src/workflows/graph.ts +0 -5
  147. package/src/workflows/index.ts +2 -0
  148. package/src/workflows/loader.ts +76 -22
  149. package/src/workflows/migrate-sources.ts +167 -0
  150. package/src/workflows/schema.ts +3 -2
  151. package/src/workflows/store.ts +557 -48
  152. 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.2.0",
4
- "description": "Workflow engine, JSON control-flow tool, and live terminal viewer for the pi coding agent",
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
+ }