@nt-ai-lab/deterministic-agent-workflow-engine 0.3.4 → 0.3.5
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/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/platform/domain/engine-events.d.ts +2 -0
- package/dist/platform/domain/engine-events.js +16 -0
- package/dist/platform/domain/workflow-engine-platform-operations.d.ts +17 -0
- package/dist/platform/domain/workflow-engine-platform-operations.js +147 -0
- package/dist/platform/domain/workflow-engine.d.ts +3 -0
- package/dist/platform/domain/workflow-engine.js +38 -138
- package/dist/platform/domain/workflow-engine.spec.d.ts +1 -0
- package/dist/platform/domain/workflow-engine.spec.js +193 -0
- package/dist/platform/domain/workflow-state-reducer.d.ts +5 -0
- package/dist/platform/domain/workflow-state-reducer.js +9 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { reviewTypeSchema, reviewVerdictSchema, reviewFindingSeveritySchema, rev
|
|
|
10
10
|
export type { ReviewType, ReviewVerdict, ReviewFindingSeverity, ReviewFindingStatus, ReviewFinding, ReviewPayload, RecordReviewInput, StoredReview, ListedReview, ReviewFilters, } from './platform/domain/review-types';
|
|
11
11
|
export { engineEventSchema } from './platform/domain/engine-events';
|
|
12
12
|
export type { EngineEvent, SessionStartedEvent, TransitionedEvent, AgentRegisteredEvent, AgentShutDownEvent, JournalEntryEvent, WriteCheckedEvent, BashCheckedEvent, PluginReadCheckedEvent, IdleCheckedEvent, IdentityVerifiedEvent, ContextRequestedEvent, ReviewRecordedEvent, } from './platform/domain/engine-events';
|
|
13
|
+
export { isPlatformOwnedEventExcludedFromWorkflowState } from './platform/domain/engine-events';
|
|
14
|
+
export { reduceWorkflowStateFromStoredEvents } from './platform/domain/workflow-state-reducer';
|
|
13
15
|
export { repositoryMetadataEventSchema } from './platform/domain/repository-tracking-events';
|
|
14
16
|
export type { DomainMetadataEvent, IssueRecordedEvent, BranchRecordedEvent, PrRecordedEvent, } from './platform/domain/repository-tracking-events';
|
|
15
17
|
export { checkBashCommand } from './platform/domain/bash-enforcement';
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export { flattenStoredEvent, stripEnvelopeKeys, toPayload, } from './platform/do
|
|
|
4
4
|
export { reflectionCategorySchema, reflectionConfidenceSchema, reflectionEvidenceSchema, reflectionFindingSchema, reflectionPayloadSchema, recordReflectionInputSchema, storedReflectionSchema, } from './platform/domain/reflection-types.js';
|
|
5
5
|
export { reviewTypeSchema, reviewVerdictSchema, reviewFindingSeveritySchema, reviewFindingStatusSchema, reviewFindingSchema, reviewPayloadSchema, recordReviewInputSchema, storedReviewSchema, listedReviewSchema, reviewFiltersSchema, } from './platform/domain/review-types.js';
|
|
6
6
|
export { engineEventSchema } from './platform/domain/engine-events.js';
|
|
7
|
+
export { isPlatformOwnedEventExcludedFromWorkflowState } from './platform/domain/engine-events.js';
|
|
8
|
+
export { reduceWorkflowStateFromStoredEvents } from './platform/domain/workflow-state-reducer.js';
|
|
7
9
|
export { repositoryMetadataEventSchema } from './platform/domain/repository-tracking-events.js';
|
|
8
10
|
export { checkBashCommand } from './platform/domain/bash-enforcement.js';
|
|
9
11
|
export { pass, fail } from './platform/domain/precondition-result.js';
|
|
@@ -444,6 +444,8 @@ export declare const engineEventSchema: z.ZodDiscriminatedUnion<"type", [z.ZodOb
|
|
|
444
444
|
reviewType: string;
|
|
445
445
|
reviewId: number;
|
|
446
446
|
}>]>;
|
|
447
|
+
/** @riviere-role domain-service */
|
|
448
|
+
export declare function isPlatformOwnedEventExcludedFromWorkflowState(type: string): boolean;
|
|
447
449
|
/** @riviere-role value-object */
|
|
448
450
|
export type EngineEvent = z.infer<typeof engineEventSchema>;
|
|
449
451
|
/** @riviere-role value-object */
|
|
@@ -97,3 +97,19 @@ export const engineEventSchema = z.discriminatedUnion('type', [
|
|
|
97
97
|
contextRequestedSchema,
|
|
98
98
|
reviewRecordedSchema,
|
|
99
99
|
]);
|
|
100
|
+
const platformOwnedEventTypesExcludedFromWorkflowState = new Set([
|
|
101
|
+
'agent-registered',
|
|
102
|
+
'agent-shut-down',
|
|
103
|
+
'journal-entry',
|
|
104
|
+
'write-checked',
|
|
105
|
+
'bash-checked',
|
|
106
|
+
'plugin-read-checked',
|
|
107
|
+
'idle-checked',
|
|
108
|
+
'identity-verified',
|
|
109
|
+
'context-requested',
|
|
110
|
+
'review-recorded',
|
|
111
|
+
]);
|
|
112
|
+
/** @riviere-role domain-service */
|
|
113
|
+
export function isPlatformOwnedEventExcludedFromWorkflowState(type) {
|
|
114
|
+
return platformOwnedEventTypesExcludedFromWorkflowState.has(type);
|
|
115
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BaseWorkflowState } from './workflow-state';
|
|
2
|
+
import type { BashForbiddenConfig } from './workflow-registry';
|
|
3
|
+
import type { EngineResult, RehydratableWorkflow, WorkflowDefinition, WorkflowEngineDeps } from './workflow-engine-types';
|
|
4
|
+
type PlatformOperationContext<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string, TOperation extends string> = {
|
|
5
|
+
readonly workflow: TWorkflow;
|
|
6
|
+
readonly engineDeps: WorkflowEngineDeps;
|
|
7
|
+
readonly factory: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation>;
|
|
8
|
+
readonly applyIdentityGate: (op: string) => EngineResult | undefined;
|
|
9
|
+
readonly persistPlatformEvent: (event: unknown) => void;
|
|
10
|
+
};
|
|
11
|
+
/** @riviere-role domain-service */
|
|
12
|
+
export declare function writeJournalWithPlatformEvents<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string, TOperation extends string>(context: PlatformOperationContext<TWorkflow, TState, TDeps, TStateName, TOperation>, agentName: string, content: string): EngineResult;
|
|
13
|
+
/** @riviere-role domain-service */
|
|
14
|
+
export declare function checkBashWithPlatformEvents<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string, TOperation extends string>(context: PlatformOperationContext<TWorkflow, TState, TDeps, TStateName, TOperation>, toolName: string, command: string, bashForbidden: BashForbiddenConfig): EngineResult;
|
|
15
|
+
/** @riviere-role domain-service */
|
|
16
|
+
export declare function checkWriteWithPlatformEvents<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string, TOperation extends string>(context: PlatformOperationContext<TWorkflow, TState, TDeps, TStateName, TOperation>, toolName: string, filePath: string, isWriteAllowed: (filePath: string, state: TState) => boolean): EngineResult;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { checkBashCommand } from './bash-enforcement.js';
|
|
2
|
+
import { formatOperationGateError, formatOperationSuccess, } from '../infra/cli/presentation/output-guidance.js';
|
|
3
|
+
import { getExpectedPrefix } from './workflow-engine-support.js';
|
|
4
|
+
/** @riviere-role domain-service */
|
|
5
|
+
export function writeJournalWithPlatformEvents(context, agentName, content) {
|
|
6
|
+
const gate = context.applyIdentityGate('write-journal');
|
|
7
|
+
if (gate !== undefined)
|
|
8
|
+
return gate;
|
|
9
|
+
context.persistPlatformEvent({
|
|
10
|
+
type: 'journal-entry',
|
|
11
|
+
at: context.engineDeps.now(),
|
|
12
|
+
agentName,
|
|
13
|
+
content,
|
|
14
|
+
});
|
|
15
|
+
const state = context.workflow.getState();
|
|
16
|
+
const body = context.factory.getOperationBody?.('write-journal', state) ?? 'Write journal entry';
|
|
17
|
+
return {
|
|
18
|
+
type: 'success',
|
|
19
|
+
output: formatOperationSuccess('write-journal', body, getExpectedPrefix(state.currentStateMachineState, context.factory.getRegistry())),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** @riviere-role domain-service */
|
|
23
|
+
export function checkBashWithPlatformEvents(context, toolName, command, bashForbidden) {
|
|
24
|
+
const state = context.workflow.getState();
|
|
25
|
+
const currentStateName = state.currentStateMachineState;
|
|
26
|
+
const currentPrefix = getExpectedPrefix(currentStateName, context.factory.getRegistry());
|
|
27
|
+
const gate = context.applyIdentityGate('bash-check');
|
|
28
|
+
if (gate !== undefined)
|
|
29
|
+
return gate;
|
|
30
|
+
if (toolName !== 'Bash') {
|
|
31
|
+
context.persistPlatformEvent({
|
|
32
|
+
type: 'bash-checked',
|
|
33
|
+
at: context.engineDeps.now(),
|
|
34
|
+
tool: toolName,
|
|
35
|
+
command,
|
|
36
|
+
allowed: true,
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
type: 'success',
|
|
40
|
+
output: '',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const exemptions = context.factory.getRegistry()[currentStateName].allowForbidden?.bash ?? [];
|
|
44
|
+
const bashCheckResult = checkBashCommand(command, bashForbidden, exemptions);
|
|
45
|
+
if (!bashCheckResult.pass) {
|
|
46
|
+
const reason = `Bash command blocked in ${currentStateName}. ${bashCheckResult.reason}`;
|
|
47
|
+
context.persistPlatformEvent({
|
|
48
|
+
type: 'bash-checked',
|
|
49
|
+
at: context.engineDeps.now(),
|
|
50
|
+
tool: toolName,
|
|
51
|
+
command,
|
|
52
|
+
allowed: false,
|
|
53
|
+
reason,
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
type: 'blocked',
|
|
57
|
+
output: formatOperationGateError('bash-check', reason, currentPrefix),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
context.persistPlatformEvent({
|
|
61
|
+
type: 'bash-checked',
|
|
62
|
+
at: context.engineDeps.now(),
|
|
63
|
+
tool: toolName,
|
|
64
|
+
command,
|
|
65
|
+
allowed: true,
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
type: 'success',
|
|
69
|
+
output: '',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** @riviere-role domain-service */
|
|
73
|
+
export function checkWriteWithPlatformEvents(context, toolName, filePath, isWriteAllowed) {
|
|
74
|
+
const state = context.workflow.getState();
|
|
75
|
+
const currentStateName = state.currentStateMachineState;
|
|
76
|
+
const currentPrefix = getExpectedPrefix(currentStateName, context.factory.getRegistry());
|
|
77
|
+
const gate = context.applyIdentityGate('write-check');
|
|
78
|
+
if (gate !== undefined)
|
|
79
|
+
return gate;
|
|
80
|
+
const writeTools = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
81
|
+
if (!writeTools.has(toolName)) {
|
|
82
|
+
context.persistPlatformEvent({
|
|
83
|
+
type: 'write-checked',
|
|
84
|
+
at: context.engineDeps.now(),
|
|
85
|
+
tool: toolName,
|
|
86
|
+
filePath,
|
|
87
|
+
allowed: true,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
type: 'success',
|
|
91
|
+
output: '',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const storePath = `${context.engineDeps.getPluginRoot()}/workflow.db`;
|
|
95
|
+
if (filePath === storePath) {
|
|
96
|
+
context.persistPlatformEvent({
|
|
97
|
+
type: 'write-checked',
|
|
98
|
+
at: context.engineDeps.now(),
|
|
99
|
+
tool: toolName,
|
|
100
|
+
filePath,
|
|
101
|
+
allowed: true,
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
type: 'success',
|
|
105
|
+
output: '',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (!(context.factory.getRegistry()[currentStateName].forbidden?.write ?? false)) {
|
|
109
|
+
context.persistPlatformEvent({
|
|
110
|
+
type: 'write-checked',
|
|
111
|
+
at: context.engineDeps.now(),
|
|
112
|
+
tool: toolName,
|
|
113
|
+
filePath,
|
|
114
|
+
allowed: true,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
type: 'success',
|
|
118
|
+
output: '',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (!isWriteAllowed(filePath, state)) {
|
|
122
|
+
const reason = `Write to '${filePath}' is forbidden in state ${currentStateName}`;
|
|
123
|
+
context.persistPlatformEvent({
|
|
124
|
+
type: 'write-checked',
|
|
125
|
+
at: context.engineDeps.now(),
|
|
126
|
+
tool: toolName,
|
|
127
|
+
filePath,
|
|
128
|
+
allowed: false,
|
|
129
|
+
reason,
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
type: 'blocked',
|
|
133
|
+
output: formatOperationGateError('write-check', reason, currentPrefix),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
context.persistPlatformEvent({
|
|
137
|
+
type: 'write-checked',
|
|
138
|
+
at: context.engineDeps.now(),
|
|
139
|
+
tool: toolName,
|
|
140
|
+
filePath,
|
|
141
|
+
allowed: true,
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
type: 'success',
|
|
145
|
+
output: '',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -10,6 +10,7 @@ export declare class WorkflowEngine<TWorkflow extends RehydratableWorkflow<TStat
|
|
|
10
10
|
constructor(factory: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation>, engineDeps: WorkflowEngineDeps, workflowDeps: TDeps);
|
|
11
11
|
startSession(sessionId: string, transcriptPath: string, repository?: string): EngineResult;
|
|
12
12
|
transaction(sessionId: string, op: string, fn: (workflow: TWorkflow) => PreconditionResult): EngineResult;
|
|
13
|
+
writeJournal(sessionId: string, agentName: string, content: string): EngineResult;
|
|
13
14
|
transition(sessionId: string, target: TStateName): EngineResult;
|
|
14
15
|
checkBash(sessionId: string, toolName: string, command: string, bashForbidden: BashForbiddenConfig): EngineResult;
|
|
15
16
|
checkWrite(sessionId: string, toolName: string, filePath: string, isWriteAllowed: (filePath: string, state: TState) => boolean): EngineResult;
|
|
@@ -23,4 +24,6 @@ export declare class WorkflowEngine<TWorkflow extends RehydratableWorkflow<TStat
|
|
|
23
24
|
private wrapEvents;
|
|
24
25
|
private applyIdentityGate;
|
|
25
26
|
private verifyIdentity;
|
|
27
|
+
private platformOperationContext;
|
|
28
|
+
private persistPlatformEvent;
|
|
26
29
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { checkBashCommand } from './bash-enforcement.js';
|
|
2
1
|
import { checkIdentity } from './identity-verification.js';
|
|
2
|
+
import { checkBashWithPlatformEvents, checkWriteWithPlatformEvents, writeJournalWithPlatformEvents, } from './workflow-engine-platform-operations.js';
|
|
3
3
|
import { buildPrefixPattern, buildProcedurePath, enrichSessionStartedEvents, getExpectedPrefix, readProcedure, } from './workflow-engine-support.js';
|
|
4
4
|
import { formatIllegalTransitionError, formatInitSuccess, formatOperationGateError, formatOperationSuccess, formatTransitionError, formatTransitionSuccess, } from '../infra/cli/presentation/output-guidance.js';
|
|
5
|
-
import {
|
|
5
|
+
import { toPayload, } from './stored-event.js';
|
|
6
|
+
import { engineEventSchema } from './engine-events.js';
|
|
6
7
|
import { WorkflowStateError, } from './workflow-state.js';
|
|
8
|
+
import { reduceWorkflowStateFromStoredEvents } from './workflow-state-reducer.js';
|
|
7
9
|
import { serializeWorkflowState } from './workflow-state-serialization.js';
|
|
8
10
|
/** @riviere-role domain-service */
|
|
9
11
|
export class WorkflowEngine {
|
|
@@ -59,6 +61,11 @@ export class WorkflowEngine {
|
|
|
59
61
|
output: formatOperationSuccess(op, body, currentPrefix)
|
|
60
62
|
};
|
|
61
63
|
}
|
|
64
|
+
writeJournal(sessionId, agentName, content) {
|
|
65
|
+
this.requireSession(sessionId);
|
|
66
|
+
const workflow = this.rehydrateFromEvents(sessionId);
|
|
67
|
+
return writeJournalWithPlatformEvents(this.platformOperationContext(sessionId, workflow), agentName, content);
|
|
68
|
+
}
|
|
62
69
|
transition(sessionId, target) {
|
|
63
70
|
this.requireSession(sessionId);
|
|
64
71
|
const workflow = this.rehydrateFromEvents(sessionId);
|
|
@@ -119,138 +126,12 @@ export class WorkflowEngine {
|
|
|
119
126
|
checkBash(sessionId, toolName, command, bashForbidden) {
|
|
120
127
|
this.requireSession(sessionId);
|
|
121
128
|
const workflow = this.rehydrateFromEvents(sessionId);
|
|
122
|
-
|
|
123
|
-
const currentStateName = workflow.getState().currentStateMachineState;
|
|
124
|
-
const currentPrefix = getExpectedPrefix(currentStateName, registry);
|
|
125
|
-
const gate = this.applyIdentityGate(sessionId, workflow, 'bash-check');
|
|
126
|
-
if (gate !== undefined)
|
|
127
|
-
return gate;
|
|
128
|
-
if (toolName !== 'Bash') {
|
|
129
|
-
workflow.appendEvent({
|
|
130
|
-
type: 'bash-checked',
|
|
131
|
-
at: this.engineDeps.now(),
|
|
132
|
-
tool: toolName,
|
|
133
|
-
command,
|
|
134
|
-
allowed: true
|
|
135
|
-
});
|
|
136
|
-
this.persistEvents(sessionId, workflow);
|
|
137
|
-
return {
|
|
138
|
-
type: 'success',
|
|
139
|
-
output: ''
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
const exemptions = registry[currentStateName].allowForbidden?.bash ?? [];
|
|
143
|
-
const bashCheckResult = checkBashCommand(command, bashForbidden, exemptions);
|
|
144
|
-
if (!bashCheckResult.pass) {
|
|
145
|
-
const reason = `Bash command blocked in ${currentStateName}. ${bashCheckResult.reason}`;
|
|
146
|
-
workflow.appendEvent({
|
|
147
|
-
type: 'bash-checked',
|
|
148
|
-
at: this.engineDeps.now(),
|
|
149
|
-
tool: toolName,
|
|
150
|
-
command,
|
|
151
|
-
allowed: false,
|
|
152
|
-
reason,
|
|
153
|
-
});
|
|
154
|
-
this.persistEvents(sessionId, workflow);
|
|
155
|
-
return {
|
|
156
|
-
type: 'blocked',
|
|
157
|
-
output: formatOperationGateError('bash-check', reason, currentPrefix)
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
workflow.appendEvent({
|
|
161
|
-
type: 'bash-checked',
|
|
162
|
-
at: this.engineDeps.now(),
|
|
163
|
-
tool: toolName,
|
|
164
|
-
command,
|
|
165
|
-
allowed: true
|
|
166
|
-
});
|
|
167
|
-
this.persistEvents(sessionId, workflow);
|
|
168
|
-
return {
|
|
169
|
-
type: 'success',
|
|
170
|
-
output: ''
|
|
171
|
-
};
|
|
129
|
+
return checkBashWithPlatformEvents(this.platformOperationContext(sessionId, workflow), toolName, command, bashForbidden);
|
|
172
130
|
}
|
|
173
131
|
checkWrite(sessionId, toolName, filePath, isWriteAllowed) {
|
|
174
132
|
this.requireSession(sessionId);
|
|
175
133
|
const workflow = this.rehydrateFromEvents(sessionId);
|
|
176
|
-
|
|
177
|
-
const currentStateName = workflow.getState().currentStateMachineState;
|
|
178
|
-
const currentPrefix = getExpectedPrefix(currentStateName, registry);
|
|
179
|
-
const gate = this.applyIdentityGate(sessionId, workflow, 'write-check');
|
|
180
|
-
if (gate !== undefined)
|
|
181
|
-
return gate;
|
|
182
|
-
const writeTools = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
183
|
-
if (!writeTools.has(toolName)) {
|
|
184
|
-
workflow.appendEvent({
|
|
185
|
-
type: 'write-checked',
|
|
186
|
-
at: this.engineDeps.now(),
|
|
187
|
-
tool: toolName,
|
|
188
|
-
filePath,
|
|
189
|
-
allowed: true
|
|
190
|
-
});
|
|
191
|
-
this.persistEvents(sessionId, workflow);
|
|
192
|
-
return {
|
|
193
|
-
type: 'success',
|
|
194
|
-
output: ''
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
const storePath = `${this.engineDeps.getPluginRoot()}/workflow.db`;
|
|
198
|
-
if (filePath === storePath) {
|
|
199
|
-
workflow.appendEvent({
|
|
200
|
-
type: 'write-checked',
|
|
201
|
-
at: this.engineDeps.now(),
|
|
202
|
-
tool: toolName,
|
|
203
|
-
filePath,
|
|
204
|
-
allowed: true
|
|
205
|
-
});
|
|
206
|
-
this.persistEvents(sessionId, workflow);
|
|
207
|
-
return {
|
|
208
|
-
type: 'success',
|
|
209
|
-
output: ''
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
if (!(registry[currentStateName].forbidden?.write ?? false)) {
|
|
213
|
-
workflow.appendEvent({
|
|
214
|
-
type: 'write-checked',
|
|
215
|
-
at: this.engineDeps.now(),
|
|
216
|
-
tool: toolName,
|
|
217
|
-
filePath,
|
|
218
|
-
allowed: true
|
|
219
|
-
});
|
|
220
|
-
this.persistEvents(sessionId, workflow);
|
|
221
|
-
return {
|
|
222
|
-
type: 'success',
|
|
223
|
-
output: ''
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
if (!isWriteAllowed(filePath, workflow.getState())) {
|
|
227
|
-
const reason = `Write to '${filePath}' is forbidden in state ${currentStateName}`;
|
|
228
|
-
workflow.appendEvent({
|
|
229
|
-
type: 'write-checked',
|
|
230
|
-
at: this.engineDeps.now(),
|
|
231
|
-
tool: toolName,
|
|
232
|
-
filePath,
|
|
233
|
-
allowed: false,
|
|
234
|
-
reason,
|
|
235
|
-
});
|
|
236
|
-
this.persistEvents(sessionId, workflow);
|
|
237
|
-
return {
|
|
238
|
-
type: 'blocked',
|
|
239
|
-
output: formatOperationGateError('write-check', reason, currentPrefix)
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
workflow.appendEvent({
|
|
243
|
-
type: 'write-checked',
|
|
244
|
-
at: this.engineDeps.now(),
|
|
245
|
-
tool: toolName,
|
|
246
|
-
filePath,
|
|
247
|
-
allowed: true
|
|
248
|
-
});
|
|
249
|
-
this.persistEvents(sessionId, workflow);
|
|
250
|
-
return {
|
|
251
|
-
type: 'success',
|
|
252
|
-
output: ''
|
|
253
|
-
};
|
|
134
|
+
return checkWriteWithPlatformEvents(this.platformOperationContext(sessionId, workflow), toolName, filePath, isWriteAllowed);
|
|
254
135
|
}
|
|
255
136
|
getState(sessionId) {
|
|
256
137
|
this.requireSession(sessionId);
|
|
@@ -272,8 +153,7 @@ export class WorkflowEngine {
|
|
|
272
153
|
}
|
|
273
154
|
rehydrateFromEvents(sessionId) {
|
|
274
155
|
const stored = this.engineDeps.store.readEvents(sessionId);
|
|
275
|
-
const
|
|
276
|
-
const state = events.reduce((accumulator, event) => this.factory.fold(accumulator, event), this.factory.initialState());
|
|
156
|
+
const state = reduceWorkflowStateFromStoredEvents(this.factory, stored);
|
|
277
157
|
return this.factory.buildWorkflow(state, this.workflowDeps);
|
|
278
158
|
}
|
|
279
159
|
persistEvents(sessionId, workflow) {
|
|
@@ -318,12 +198,12 @@ export class WorkflowEngine {
|
|
|
318
198
|
const pattern = buildPrefixPattern(registry);
|
|
319
199
|
const messages = this.engineDeps.transcriptReader.readMessages(transcriptPath);
|
|
320
200
|
const identityCheckResult = checkIdentity(messages, pattern);
|
|
321
|
-
this.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
201
|
+
this.persistPlatformEvent(sessionId, workflow.getState(), {
|
|
202
|
+
type: 'identity-verified',
|
|
203
|
+
at: this.engineDeps.now(),
|
|
204
|
+
status: identityCheckResult.status,
|
|
205
|
+
transcriptPath,
|
|
206
|
+
});
|
|
327
207
|
if (identityCheckResult.status === 'lost') {
|
|
328
208
|
const currentProcedure = readProcedure(this.engineDeps, state);
|
|
329
209
|
return [
|
|
@@ -339,4 +219,24 @@ export class WorkflowEngine {
|
|
|
339
219
|
}
|
|
340
220
|
return undefined;
|
|
341
221
|
}
|
|
222
|
+
platformOperationContext(sessionId, workflow) {
|
|
223
|
+
return {
|
|
224
|
+
workflow,
|
|
225
|
+
engineDeps: this.engineDeps,
|
|
226
|
+
factory: this.factory,
|
|
227
|
+
applyIdentityGate: (op) => this.applyIdentityGate(sessionId, workflow, op),
|
|
228
|
+
persistPlatformEvent: (event) => this.persistPlatformEvent(sessionId, workflow.getState(), event),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
persistPlatformEvent(sessionId, state, event) {
|
|
232
|
+
const platformEvent = engineEventSchema.parse(event);
|
|
233
|
+
this.engineDeps.store.appendEvents(sessionId, [{
|
|
234
|
+
envelope: {
|
|
235
|
+
type: platformEvent.type,
|
|
236
|
+
at: platformEvent.at,
|
|
237
|
+
state: state.currentStateMachineState,
|
|
238
|
+
},
|
|
239
|
+
payload: toPayload(platformEvent),
|
|
240
|
+
}]);
|
|
241
|
+
}
|
|
342
242
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, it, } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { pass, WorkflowEngine, WorkflowStateError, } from '../../index.js';
|
|
4
|
+
function isSessionStartedEvent(event) {
|
|
5
|
+
return event.type === 'session-started';
|
|
6
|
+
}
|
|
7
|
+
class StrictPlanningWorkflow {
|
|
8
|
+
state;
|
|
9
|
+
pendingEvents;
|
|
10
|
+
constructor(state, pendingEvents = []) {
|
|
11
|
+
this.state = state;
|
|
12
|
+
this.pendingEvents = pendingEvents;
|
|
13
|
+
}
|
|
14
|
+
getState() {
|
|
15
|
+
return this.state;
|
|
16
|
+
}
|
|
17
|
+
appendEvent(event) {
|
|
18
|
+
if (!isSessionStartedEvent(event)) {
|
|
19
|
+
throw new WorkflowStateError(`Unexpected event in appendEvent: ${event.type}`);
|
|
20
|
+
}
|
|
21
|
+
this.pendingEvents = [...this.pendingEvents, event];
|
|
22
|
+
this.state = {
|
|
23
|
+
...this.state,
|
|
24
|
+
transcriptPath: event.transcriptPath,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
getPendingEvents() {
|
|
28
|
+
return this.pendingEvents;
|
|
29
|
+
}
|
|
30
|
+
startSession(transcriptPath, repository) {
|
|
31
|
+
void repository;
|
|
32
|
+
this.state = {
|
|
33
|
+
...this.state,
|
|
34
|
+
transcriptPath,
|
|
35
|
+
};
|
|
36
|
+
this.pendingEvents = [...this.pendingEvents, {
|
|
37
|
+
type: 'session-started',
|
|
38
|
+
at: '2026-01-01T00:00:00Z',
|
|
39
|
+
transcriptPath,
|
|
40
|
+
currentState: this.state.currentStateMachineState,
|
|
41
|
+
states: ['PLANNING'],
|
|
42
|
+
}];
|
|
43
|
+
}
|
|
44
|
+
getTranscriptPath() {
|
|
45
|
+
return this.state.transcriptPath;
|
|
46
|
+
}
|
|
47
|
+
registerAgent(agentType, agentId) {
|
|
48
|
+
void agentType;
|
|
49
|
+
void agentId;
|
|
50
|
+
return pass();
|
|
51
|
+
}
|
|
52
|
+
handleTeammateIdle(agentName) {
|
|
53
|
+
void agentName;
|
|
54
|
+
return pass();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
class InMemoryWorkflowEventStore {
|
|
58
|
+
eventsBySessionId = new Map();
|
|
59
|
+
readEvents(sessionId) {
|
|
60
|
+
return this.eventsBySessionId.get(sessionId) ?? [];
|
|
61
|
+
}
|
|
62
|
+
appendEvents(sessionId, events) {
|
|
63
|
+
const existingEvents = this.eventsBySessionId.get(sessionId) ?? [];
|
|
64
|
+
this.eventsBySessionId.set(sessionId, [...existingEvents, ...events]);
|
|
65
|
+
}
|
|
66
|
+
sessionExists(sessionId) {
|
|
67
|
+
return this.eventsBySessionId.has(sessionId);
|
|
68
|
+
}
|
|
69
|
+
hasSessionStarted(sessionId) {
|
|
70
|
+
return this.readEvents(sessionId).some((event) => event.envelope.type === 'session-started');
|
|
71
|
+
}
|
|
72
|
+
recordReflection(sessionId, createdAt, input) {
|
|
73
|
+
void sessionId;
|
|
74
|
+
void createdAt;
|
|
75
|
+
void input;
|
|
76
|
+
throw new WorkflowStateError('Reflection storage is not configured for this test');
|
|
77
|
+
}
|
|
78
|
+
listReflections(sessionId) {
|
|
79
|
+
void sessionId;
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
recordReview(sessionId, createdAt, input) {
|
|
83
|
+
void sessionId;
|
|
84
|
+
void createdAt;
|
|
85
|
+
void input;
|
|
86
|
+
throw new WorkflowStateError('Review storage is not configured for this test');
|
|
87
|
+
}
|
|
88
|
+
recordReviewWithEvent(sessionId, createdAt, input, eventState) {
|
|
89
|
+
void sessionId;
|
|
90
|
+
void createdAt;
|
|
91
|
+
void input;
|
|
92
|
+
void eventState;
|
|
93
|
+
throw new WorkflowStateError('Review storage is not configured for this test');
|
|
94
|
+
}
|
|
95
|
+
listSessionReviews(sessionId) {
|
|
96
|
+
void sessionId;
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
listReviews(filters) {
|
|
100
|
+
void filters;
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const workflowDefinition = {
|
|
105
|
+
fold(state, event) {
|
|
106
|
+
if (!isSessionStartedEvent(event)) {
|
|
107
|
+
throw new WorkflowStateError(`Unexpected event in fold: ${event.type}`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
...state,
|
|
111
|
+
transcriptPath: event.transcriptPath,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
buildWorkflow(state) {
|
|
115
|
+
return new StrictPlanningWorkflow(state);
|
|
116
|
+
},
|
|
117
|
+
stateSchema: z.literal('PLANNING'),
|
|
118
|
+
initialState() {
|
|
119
|
+
return {
|
|
120
|
+
currentStateMachineState: 'PLANNING',
|
|
121
|
+
transcriptPath: '',
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
getRegistry() {
|
|
125
|
+
return {
|
|
126
|
+
PLANNING: {
|
|
127
|
+
emoji: '🧭',
|
|
128
|
+
agentInstructions: 'states/planning.md',
|
|
129
|
+
canTransitionTo: [],
|
|
130
|
+
allowedWorkflowOperations: ['write'],
|
|
131
|
+
forbidden: { write: true },
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
buildTransitionContext(state, from, to) {
|
|
136
|
+
return {
|
|
137
|
+
state,
|
|
138
|
+
from,
|
|
139
|
+
to,
|
|
140
|
+
gitInfo: {
|
|
141
|
+
currentBranch: 'main',
|
|
142
|
+
workingTreeClean: true,
|
|
143
|
+
headCommit: 'abc123',
|
|
144
|
+
changedFilesVsDefault: [],
|
|
145
|
+
hasCommitsVsDefault: false,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
function createEngine() {
|
|
151
|
+
const store = new InMemoryWorkflowEventStore();
|
|
152
|
+
const engineDeps = {
|
|
153
|
+
store,
|
|
154
|
+
getPluginRoot: () => '/plugin-root',
|
|
155
|
+
getEnvFilePath: () => '/plugin-root/.env',
|
|
156
|
+
readFile: () => '',
|
|
157
|
+
appendToFile: () => undefined,
|
|
158
|
+
now: () => '2026-01-01T00:00:00Z',
|
|
159
|
+
transcriptReader: { readMessages: () => [] },
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
store,
|
|
163
|
+
engine: new WorkflowEngine(workflowDefinition, engineDeps, {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
describe('WorkflowEngine platform-owned events', () => {
|
|
167
|
+
it('persists journal and write-check events without routing them through the consumer workflow', () => {
|
|
168
|
+
const { engine, store, } = createEngine();
|
|
169
|
+
engine.startSession('session-1', '/transcripts/session-1.jsonl');
|
|
170
|
+
const journalResult = engine.writeJournal('session-1', 'gpt-5.4', 'Captured planning context.');
|
|
171
|
+
const writeCheckResult = engine.checkWrite('session-1', 'Read', '', () => true);
|
|
172
|
+
const stateResult = engine.getState('session-1');
|
|
173
|
+
expect(journalResult.type).toBe('success');
|
|
174
|
+
expect(writeCheckResult).toStrictEqual({
|
|
175
|
+
type: 'success',
|
|
176
|
+
output: '',
|
|
177
|
+
});
|
|
178
|
+
expect(stateResult).toStrictEqual({
|
|
179
|
+
type: 'success',
|
|
180
|
+
output: JSON.stringify({
|
|
181
|
+
currentStateMachineState: 'PLANNING',
|
|
182
|
+
transcriptPath: '/transcripts/session-1.jsonl',
|
|
183
|
+
}, null, 2),
|
|
184
|
+
});
|
|
185
|
+
expect(store.readEvents('session-1').map((event) => event.envelope.type)).toStrictEqual([
|
|
186
|
+
'session-started',
|
|
187
|
+
'identity-verified',
|
|
188
|
+
'journal-entry',
|
|
189
|
+
'identity-verified',
|
|
190
|
+
'write-checked',
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type StoredEvent } from './stored-event';
|
|
2
|
+
import type { BaseWorkflowState } from './workflow-state';
|
|
3
|
+
import type { RehydratableWorkflow, WorkflowDefinition } from './workflow-engine-types';
|
|
4
|
+
/** @riviere-role domain-service */
|
|
5
|
+
export declare function reduceWorkflowStateFromStoredEvents<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string, TOperation extends string>(workflowDefinition: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation>, storedEvents: readonly StoredEvent[]): TState;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { isPlatformOwnedEventExcludedFromWorkflowState } from './engine-events.js';
|
|
2
|
+
import { flattenStoredEvent, } from './stored-event.js';
|
|
3
|
+
/** @riviere-role domain-service */
|
|
4
|
+
export function reduceWorkflowStateFromStoredEvents(workflowDefinition, storedEvents) {
|
|
5
|
+
return storedEvents
|
|
6
|
+
.map(flattenStoredEvent)
|
|
7
|
+
.filter((event) => !isPlatformOwnedEventExcludedFromWorkflowState(event.type))
|
|
8
|
+
.reduce((workflowState, event) => workflowDefinition.fold(workflowState, event), workflowDefinition.initialState());
|
|
9
|
+
}
|