@wichayutdew/pi-workflows 2.6.0 → 2.7.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 +30 -0
- package/dist/index.js +141 -17
- package/examples/starter-kit/agents/planner.md +4 -0
- package/examples/starter-kit/agents/reviewer.md +4 -0
- package/examples/starter-kit/agents/scout.md +4 -0
- package/examples/starter-kit/agents/worker.md +4 -0
- package/examples/starter-kit/agents/workspace-preparer.md +4 -0
- package/examples/starter-kit/investigate.workflow.yaml +34 -64
- package/examples/starter-kit/jira.workflow.yaml +75 -0
- package/examples/starter-kit/mr-comment.workflow.yaml +48 -115
- package/examples/starter-kit/mr-review.workflow.yaml +36 -93
- package/examples/starter-kit/settings.yaml +2 -1
- package/examples/starter-kit/steps/investigate/investigate.md +18 -55
- package/examples/starter-kit/steps/investigate/retrieve.md +15 -50
- package/examples/starter-kit/steps/investigate/validate.md +10 -36
- package/examples/starter-kit/steps/jira/create.md +25 -0
- package/examples/starter-kit/steps/jira/draft.md +18 -0
- package/examples/starter-kit/steps/jira/plan.md +30 -0
- package/examples/starter-kit/steps/mr-comment/checkout-source.md +7 -60
- package/examples/starter-kit/steps/mr-comment/fetch.md +11 -36
- package/examples/starter-kit/steps/mr-comment/implement.md +10 -34
- package/examples/starter-kit/steps/mr-comment/plan.md +47 -70
- package/examples/starter-kit/steps/mr-comment/publish.md +11 -32
- package/examples/starter-kit/steps/mr-comment/verify.md +8 -42
- package/examples/starter-kit/steps/mr-review/fetch.md +8 -46
- package/examples/starter-kit/steps/mr-review/publish-approved.md +7 -38
- package/examples/starter-kit/steps/mr-review/review-for-approval.md +24 -113
- package/examples/starter-kit/steps/mr-review/verify-published.md +9 -30
- package/examples/starter-kit/steps/shared/prepare-workspace.md +9 -105
- package/examples/starter-kit/steps/shared/publish-remote.md +8 -37
- package/examples/starter-kit/steps/ticket/implement.md +11 -58
- package/examples/starter-kit/steps/ticket/plan.md +49 -171
- package/examples/starter-kit/steps/ticket/verify.md +12 -98
- package/examples/starter-kit/steps/work/implement.md +11 -58
- package/examples/starter-kit/steps/work/plan.md +42 -130
- package/examples/starter-kit/steps/work/verify.md +11 -58
- package/examples/starter-kit/ticket.workflow.yaml +38 -81
- package/examples/starter-kit/work.workflow.yaml +34 -72
- package/package.json +7 -4
- package/schemas/workflow.schema.json +61 -0
- package/scripts/patch-herdr-agent-state.mjs +36 -0
- package/src/config/types.ts +9 -0
- package/src/config/validation/step.ts +117 -0
- package/src/harness/artifact-contract.ts +46 -0
- package/src/harness/gate-submission-action.ts +28 -0
- package/src/harness/status-actions.ts +19 -0
- package/src/herdr-workflow-state.ts +65 -0
|
@@ -2,6 +2,7 @@ import { isAbsolute, win32 } from 'node:path';
|
|
|
2
2
|
import {
|
|
3
3
|
MAX_WORKSPACE_ALLOWED_ROOTS,
|
|
4
4
|
MAX_WORKSPACE_PATH_CHARS,
|
|
5
|
+
type ArtifactContract,
|
|
5
6
|
type PromptSpec,
|
|
6
7
|
type StepWorkspaceBinding,
|
|
7
8
|
type WorkflowGate,
|
|
@@ -68,6 +69,106 @@ function parseTransitions(
|
|
|
68
69
|
return transitions;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function parseArtifactContract(
|
|
73
|
+
value: unknown,
|
|
74
|
+
path: string,
|
|
75
|
+
errors: ValidationErrors,
|
|
76
|
+
): ArtifactContract | undefined {
|
|
77
|
+
if (value === undefined) return undefined;
|
|
78
|
+
if (!isJsonObject(value)) {
|
|
79
|
+
errors.push(`${path}: expected an object`);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
rejectUnknownKeys(
|
|
83
|
+
value,
|
|
84
|
+
[
|
|
85
|
+
'maxChars',
|
|
86
|
+
'requiredSubstrings',
|
|
87
|
+
'forbiddenSubstrings',
|
|
88
|
+
'equalOccurrenceGroups',
|
|
89
|
+
'onValidationFailure',
|
|
90
|
+
],
|
|
91
|
+
path,
|
|
92
|
+
errors,
|
|
93
|
+
);
|
|
94
|
+
if (value.maxChars === undefined) {
|
|
95
|
+
errors.push(`${path}.maxChars: expected an integer from 1 to 200000`);
|
|
96
|
+
}
|
|
97
|
+
const maxChars = readInteger(
|
|
98
|
+
value.maxChars,
|
|
99
|
+
200_000,
|
|
100
|
+
`${path}.maxChars`,
|
|
101
|
+
errors,
|
|
102
|
+
{ min: 1, max: 200_000 },
|
|
103
|
+
);
|
|
104
|
+
const parseSubstrings = (field: string): Array<string> => {
|
|
105
|
+
const values = readStringList(
|
|
106
|
+
value[field],
|
|
107
|
+
`${path}.${field}`,
|
|
108
|
+
errors,
|
|
109
|
+
/.+/,
|
|
110
|
+
);
|
|
111
|
+
if (values.length > 32) {
|
|
112
|
+
errors.push(`${path}.${field}: at most 32 values are allowed`);
|
|
113
|
+
}
|
|
114
|
+
values.forEach((substring, index) => {
|
|
115
|
+
if (substring.length > 1_024) {
|
|
116
|
+
errors.push(`${path}.${field}[${index}]: exceeds 1024 characters`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return values;
|
|
120
|
+
};
|
|
121
|
+
const equalOccurrenceGroups = (() => {
|
|
122
|
+
if (value.equalOccurrenceGroups === undefined) return [];
|
|
123
|
+
if (!Array.isArray(value.equalOccurrenceGroups)) {
|
|
124
|
+
errors.push(`${path}.equalOccurrenceGroups: expected an array`);
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
if (value.equalOccurrenceGroups.length > 32) {
|
|
128
|
+
errors.push(
|
|
129
|
+
`${path}.equalOccurrenceGroups: at most 32 groups are allowed`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return value.equalOccurrenceGroups.reduce<Array<Array<string>>>(
|
|
133
|
+
(groups, group, index) => {
|
|
134
|
+
const groupPath = `${path}.equalOccurrenceGroups[${index}]`;
|
|
135
|
+
const values = readStringList(group, groupPath, errors, /.+/);
|
|
136
|
+
if (values.length < 2) {
|
|
137
|
+
errors.push(`${groupPath}: at least two values are required`);
|
|
138
|
+
}
|
|
139
|
+
if (values.length > 32) {
|
|
140
|
+
errors.push(`${groupPath}: at most 32 values are allowed`);
|
|
141
|
+
}
|
|
142
|
+
values.forEach((substring, valueIndex) => {
|
|
143
|
+
if (substring.length > 1_024) {
|
|
144
|
+
errors.push(`${groupPath}[${valueIndex}]: exceeds 1024 characters`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return [...groups, values];
|
|
148
|
+
},
|
|
149
|
+
[],
|
|
150
|
+
);
|
|
151
|
+
})();
|
|
152
|
+
const onValidationFailure =
|
|
153
|
+
value.onValidationFailure === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: readString(
|
|
156
|
+
value.onValidationFailure,
|
|
157
|
+
`${path}.onValidationFailure`,
|
|
158
|
+
errors,
|
|
159
|
+
);
|
|
160
|
+
if (onValidationFailure !== undefined && onValidationFailure !== 'retry') {
|
|
161
|
+
errors.push(`${path}.onValidationFailure: expected retry`);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
maxChars,
|
|
165
|
+
requiredSubstrings: parseSubstrings('requiredSubstrings'),
|
|
166
|
+
forbiddenSubstrings: parseSubstrings('forbiddenSubstrings'),
|
|
167
|
+
equalOccurrenceGroups,
|
|
168
|
+
...(onValidationFailure === 'retry' ? { onValidationFailure } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
71
172
|
function parseGate(
|
|
72
173
|
value: unknown,
|
|
73
174
|
path: string,
|
|
@@ -86,6 +187,7 @@ function parseGate(
|
|
|
86
187
|
'approvedOutcome',
|
|
87
188
|
'rejectedOutcome',
|
|
88
189
|
'timeoutMs',
|
|
190
|
+
'artifactContract',
|
|
89
191
|
],
|
|
90
192
|
path,
|
|
91
193
|
errors,
|
|
@@ -120,6 +222,11 @@ function parseGate(
|
|
|
120
222
|
errors,
|
|
121
223
|
{ pattern: OUTCOME_PATTERN },
|
|
122
224
|
);
|
|
225
|
+
const artifactContract = parseArtifactContract(
|
|
226
|
+
value.artifactContract,
|
|
227
|
+
`${path}.artifactContract`,
|
|
228
|
+
errors,
|
|
229
|
+
);
|
|
123
230
|
if (provider === 'prompt' && value.timeoutMs !== undefined) {
|
|
124
231
|
errors.push(`${path}.timeoutMs: only valid with provider "plannotator"`);
|
|
125
232
|
}
|
|
@@ -136,12 +243,14 @@ function parseGate(
|
|
|
136
243
|
submitOutcome,
|
|
137
244
|
approvedOutcome,
|
|
138
245
|
rejectedOutcome,
|
|
246
|
+
...(artifactContract ? { artifactContract } : {}),
|
|
139
247
|
}
|
|
140
248
|
: {
|
|
141
249
|
provider,
|
|
142
250
|
submitOutcome,
|
|
143
251
|
approvedOutcome,
|
|
144
252
|
rejectedOutcome,
|
|
253
|
+
...(artifactContract ? { artifactContract } : {}),
|
|
145
254
|
timeoutMs: readInteger(
|
|
146
255
|
value.timeoutMs,
|
|
147
256
|
30_000,
|
|
@@ -312,6 +421,14 @@ export function parseWorkflowStep(
|
|
|
312
421
|
`${path}.transitions: submitOutcome is handled by the gate and must not be a transition`,
|
|
313
422
|
);
|
|
314
423
|
}
|
|
424
|
+
if (
|
|
425
|
+
gate.artifactContract?.onValidationFailure === 'retry' &&
|
|
426
|
+
!Object.hasOwn(transitions, 'retry')
|
|
427
|
+
) {
|
|
428
|
+
errors.push(
|
|
429
|
+
`${path}.transitions: artifact-contract retry requires a "retry" transition`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
315
432
|
}
|
|
316
433
|
if (workspace) {
|
|
317
434
|
workspace.bindOn.forEach((outcome) => {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ArtifactContract } from '../config/types.ts';
|
|
2
|
+
|
|
3
|
+
function countOccurrences(value: string, substring: string): number {
|
|
4
|
+
let count = 0;
|
|
5
|
+
let offset = 0;
|
|
6
|
+
while (true) {
|
|
7
|
+
const match = value.indexOf(substring, offset);
|
|
8
|
+
if (match === -1) return count;
|
|
9
|
+
count += 1;
|
|
10
|
+
offset = match + substring.length;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateArtifactContract(
|
|
15
|
+
artifact: string,
|
|
16
|
+
contract: ArtifactContract | undefined,
|
|
17
|
+
): string | undefined {
|
|
18
|
+
if (!contract) return undefined;
|
|
19
|
+
if (artifact.length > contract.maxChars) {
|
|
20
|
+
return `gate artifact exceeds ${contract.maxChars} characters`;
|
|
21
|
+
}
|
|
22
|
+
const required = contract.requiredSubstrings.find(
|
|
23
|
+
(substring) => !artifact.includes(substring),
|
|
24
|
+
);
|
|
25
|
+
if (required) {
|
|
26
|
+
return `gate artifact is missing required text: ${JSON.stringify(required)}`;
|
|
27
|
+
}
|
|
28
|
+
const forbidden = contract.forbiddenSubstrings.find((substring) =>
|
|
29
|
+
artifact.includes(substring),
|
|
30
|
+
);
|
|
31
|
+
if (forbidden) {
|
|
32
|
+
return `gate artifact contains forbidden text: ${JSON.stringify(forbidden)}`;
|
|
33
|
+
}
|
|
34
|
+
for (const group of contract.equalOccurrenceGroups) {
|
|
35
|
+
const counts = group.map((substring) =>
|
|
36
|
+
countOccurrences(artifact, substring),
|
|
37
|
+
);
|
|
38
|
+
if (counts.some((count) => count === 0)) {
|
|
39
|
+
return `gate artifact is missing required repeated text: ${JSON.stringify(group)}`;
|
|
40
|
+
}
|
|
41
|
+
if (!counts.every((count) => count === counts[0])) {
|
|
42
|
+
return `gate artifact has unequal repeated text counts: ${JSON.stringify(group)}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { LoadedWorkflow } from '../config/types.ts';
|
|
2
2
|
import type { WorkflowRun } from '../engine/state.ts';
|
|
3
|
+
import { validateArtifactContract } from './artifact-contract.ts';
|
|
3
4
|
import {
|
|
4
5
|
attachGateReviewId,
|
|
6
|
+
advanceRun,
|
|
5
7
|
beginGate,
|
|
6
8
|
failGate,
|
|
7
9
|
failRun,
|
|
@@ -19,6 +21,7 @@ type HarnessActionContext = Pick<
|
|
|
19
21
|
| 'pi'
|
|
20
22
|
| 'restoreBaselineTools'
|
|
21
23
|
| 'run'
|
|
24
|
+
| 'settleAfterTransition'
|
|
22
25
|
| 'sessionEpoch'
|
|
23
26
|
| 'updateStatus'
|
|
24
27
|
>;
|
|
@@ -63,6 +66,31 @@ async function submitGate(
|
|
|
63
66
|
this.dependencies.createRequestId();
|
|
64
67
|
const step = workflow.definition.steps[originalRun.currentStepId];
|
|
65
68
|
if (!step?.gate) throw new Error('Current step has no gate');
|
|
69
|
+
const contractError = validateArtifactContract(
|
|
70
|
+
artifact,
|
|
71
|
+
step.gate.artifactContract,
|
|
72
|
+
);
|
|
73
|
+
if (contractError) {
|
|
74
|
+
if (step.gate.artifactContract?.onValidationFailure !== 'retry') {
|
|
75
|
+
throw new Error(contractError);
|
|
76
|
+
}
|
|
77
|
+
const retrySummary = `Artifact contract failed: ${contractError}`;
|
|
78
|
+
this.run = advanceRun(
|
|
79
|
+
workflow,
|
|
80
|
+
originalRun,
|
|
81
|
+
'retry',
|
|
82
|
+
retrySummary,
|
|
83
|
+
this.dependencies.now(),
|
|
84
|
+
);
|
|
85
|
+
this.persist();
|
|
86
|
+
this.updateStatus();
|
|
87
|
+
this.settleAfterTransition(workflow, {
|
|
88
|
+
stepId: originalRun.currentStepId,
|
|
89
|
+
outcome: 'retry',
|
|
90
|
+
summary: retrySummary,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
66
94
|
|
|
67
95
|
this.run = beginGate(
|
|
68
96
|
workflow,
|
|
@@ -78,6 +78,25 @@ function workflowStatusSnapshot(
|
|
|
78
78
|
|
|
79
79
|
function updateStatus(this: StatusContext): void {
|
|
80
80
|
refreshStatusWhileRunning.call(this);
|
|
81
|
+
const run = this.run;
|
|
82
|
+
this.pi.events.emit('pi-workflows:state', {
|
|
83
|
+
state:
|
|
84
|
+
run?.status === 'running'
|
|
85
|
+
? 'working'
|
|
86
|
+
: run?.status === 'awaiting-gate' || run?.status === 'paused'
|
|
87
|
+
? 'blocked'
|
|
88
|
+
: run?.status === 'completed'
|
|
89
|
+
? 'completed'
|
|
90
|
+
: 'interrupted',
|
|
91
|
+
workflowId: run?.workflowId,
|
|
92
|
+
stepId: run?.currentStepId,
|
|
93
|
+
message:
|
|
94
|
+
run?.status === 'completed'
|
|
95
|
+
? `Workflow "${run.workflowId}" completed`
|
|
96
|
+
: run?.status === 'paused'
|
|
97
|
+
? run.pauseReason
|
|
98
|
+
: undefined,
|
|
99
|
+
});
|
|
81
100
|
if (!this.latestContext) return;
|
|
82
101
|
if (this.legacyProgressWidgetContext !== this.latestContext) {
|
|
83
102
|
this.latestContext.ui.setWidget(LEGACY_PROGRESS_WIDGET_KEY, undefined);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
|
|
4
|
+
const source = 'herdr:pi-workflows';
|
|
5
|
+
|
|
6
|
+
type WorkflowState = {
|
|
7
|
+
state: 'working' | 'blocked' | 'completed' | 'interrupted';
|
|
8
|
+
workflowId?: string;
|
|
9
|
+
stepId?: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function herdrWorkflowState(pi: ExtensionAPI): void {
|
|
14
|
+
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
15
|
+
const paneId = process.env.HERDR_PANE_ID;
|
|
16
|
+
if (process.env.HERDR_ENV !== '1' || !socketPath || !paneId) return;
|
|
17
|
+
|
|
18
|
+
let seq = Date.now() * 1000;
|
|
19
|
+
let last = '';
|
|
20
|
+
pi.events.on('pi-workflows:state', (data: unknown) => {
|
|
21
|
+
if (!data || typeof data !== 'object') return;
|
|
22
|
+
const event = data as WorkflowState;
|
|
23
|
+
if (
|
|
24
|
+
!['working', 'blocked', 'completed', 'interrupted'].includes(event.state)
|
|
25
|
+
)
|
|
26
|
+
return;
|
|
27
|
+
const state =
|
|
28
|
+
event.state === 'working'
|
|
29
|
+
? 'working'
|
|
30
|
+
: event.state === 'blocked'
|
|
31
|
+
? 'blocked'
|
|
32
|
+
: 'idle';
|
|
33
|
+
const message =
|
|
34
|
+
event.message ??
|
|
35
|
+
(event.workflowId
|
|
36
|
+
? `Workflow "${event.workflowId}" ${event.state}`
|
|
37
|
+
: undefined);
|
|
38
|
+
const fingerprint = `${state}:${message ?? ''}`;
|
|
39
|
+
if (fingerprint === last) return;
|
|
40
|
+
last = fingerprint;
|
|
41
|
+
const request = {
|
|
42
|
+
id: `${source}:${Date.now()}:${++seq}`,
|
|
43
|
+
method: 'pane.report_agent',
|
|
44
|
+
params: {
|
|
45
|
+
pane_id: paneId,
|
|
46
|
+
source,
|
|
47
|
+
agent: 'pi-workflows',
|
|
48
|
+
state,
|
|
49
|
+
message,
|
|
50
|
+
seq,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const socket = net.createConnection(socketPath);
|
|
54
|
+
const timeout = setTimeout(() => socket.destroy(), 1500);
|
|
55
|
+
timeout.unref();
|
|
56
|
+
socket.on('connect', () => socket.write(`${JSON.stringify(request)}\n`));
|
|
57
|
+
socket.on('data', () => socket.destroy());
|
|
58
|
+
socket.on('close', () => {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
});
|
|
61
|
+
socket.on('error', () => {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|