@rigkit/engine 0.1.8
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 +5 -0
- package/package.json +34 -0
- package/src/authoring.ts +334 -0
- package/src/db/index.ts +157 -0
- package/src/db/schema/core.ts +75 -0
- package/src/db/schema/index.ts +7 -0
- package/src/engine.test.ts +866 -0
- package/src/engine.ts +2059 -0
- package/src/env-file.ts +52 -0
- package/src/hash.ts +21 -0
- package/src/index.ts +34 -0
- package/src/provider/types.ts +139 -0
- package/src/state.ts +318 -0
- package/src/types.ts +604 -0
- package/src/version.ts +1 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,2059 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { isRigkitConfig, isProviderDefinition, isWorkflowNode } from "./authoring.ts";
|
|
5
|
+
import { loadDotEnv } from "./env-file.ts";
|
|
6
|
+
import { hash, stableJson } from "./hash.ts";
|
|
7
|
+
import type {
|
|
8
|
+
BaseProviderPlugin,
|
|
9
|
+
InteractionPresenter,
|
|
10
|
+
InteractionPresentationRequest,
|
|
11
|
+
ProviderFactory,
|
|
12
|
+
ProviderRuntimeContext,
|
|
13
|
+
SshConnection,
|
|
14
|
+
WorkflowProviderController,
|
|
15
|
+
WorkflowWorkspaceProvider,
|
|
16
|
+
} from "./provider/types.ts";
|
|
17
|
+
import {
|
|
18
|
+
createStateStore,
|
|
19
|
+
type SnapshotRecord,
|
|
20
|
+
type StateService,
|
|
21
|
+
type StateServiceFactory,
|
|
22
|
+
type WorkflowNodeRunRecord,
|
|
23
|
+
} from "./state.ts";
|
|
24
|
+
import type {
|
|
25
|
+
EventHandler,
|
|
26
|
+
JsonObject,
|
|
27
|
+
JsonValue,
|
|
28
|
+
LoadedProviderDefinition,
|
|
29
|
+
LoadedWorkflow,
|
|
30
|
+
LocalWorkspaceRuntime,
|
|
31
|
+
OutputSchema,
|
|
32
|
+
ProviderRuntimeMap,
|
|
33
|
+
ProviderWorkspaceContext,
|
|
34
|
+
WorkflowInputFieldDefinition,
|
|
35
|
+
WorkflowDefinition,
|
|
36
|
+
WorkflowEvent,
|
|
37
|
+
WorkflowNodeDefinition,
|
|
38
|
+
WorkflowOperationDefinition,
|
|
39
|
+
WorkflowHostCapabilityRequirement,
|
|
40
|
+
WorkflowHostMethodRequirement,
|
|
41
|
+
WorkflowPlan,
|
|
42
|
+
WorkflowPlanNode,
|
|
43
|
+
WorkflowProviderMap,
|
|
44
|
+
WorkflowTaskNode,
|
|
45
|
+
WorkspaceRecord,
|
|
46
|
+
WorkspaceRuntimeRecord,
|
|
47
|
+
} from "./types.ts";
|
|
48
|
+
|
|
49
|
+
export type CreateDevMachineEngineOptions = {
|
|
50
|
+
projectDir?: string;
|
|
51
|
+
configPath?: string;
|
|
52
|
+
statePath?: string;
|
|
53
|
+
state?: StateService;
|
|
54
|
+
providers?: BaseProviderPlugin[];
|
|
55
|
+
providerFactory?: ProviderFactory;
|
|
56
|
+
stateFactory?: StateServiceFactory;
|
|
57
|
+
interaction?: {
|
|
58
|
+
present?: InteractionPresenter;
|
|
59
|
+
};
|
|
60
|
+
local?: Partial<LocalWorkspaceRuntime>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type { InteractionPresenter, InteractionPresentationRequest };
|
|
64
|
+
|
|
65
|
+
export type EngineLoadResult = {
|
|
66
|
+
workflow: LoadedWorkflow;
|
|
67
|
+
workflows: LoadedWorkflow[];
|
|
68
|
+
projectDir: string;
|
|
69
|
+
configPath: string;
|
|
70
|
+
statePath: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type EngineProjectInfo = {
|
|
74
|
+
projectDir: string;
|
|
75
|
+
configPath: string;
|
|
76
|
+
statePath: string;
|
|
77
|
+
workflows: WorkflowSummary[];
|
|
78
|
+
workflow?: WorkflowSummary;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type WorkflowSummary = {
|
|
82
|
+
name: string;
|
|
83
|
+
providers: string[];
|
|
84
|
+
nodes: string[];
|
|
85
|
+
operations: string[];
|
|
86
|
+
createsWorkspace: boolean;
|
|
87
|
+
workspace?: LoadedWorkflow["workspace"];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type EngineOperationSource = "core" | "config";
|
|
91
|
+
|
|
92
|
+
export type EngineOperationKind = "command" | "workspace-action";
|
|
93
|
+
|
|
94
|
+
export type EngineOperationCliPosition = {
|
|
95
|
+
name: string;
|
|
96
|
+
index: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type EngineOperationCliOption = {
|
|
100
|
+
name: string;
|
|
101
|
+
flag: string;
|
|
102
|
+
aliases?: string[];
|
|
103
|
+
required?: boolean;
|
|
104
|
+
runtime?: boolean;
|
|
105
|
+
type?: "string" | "boolean" | "number";
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type EngineOperationCli = {
|
|
109
|
+
positionals?: EngineOperationCliPosition[];
|
|
110
|
+
options?: EngineOperationCliOption[];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export type EngineOperationSummary = {
|
|
114
|
+
workflow: string;
|
|
115
|
+
id: string;
|
|
116
|
+
aliases?: readonly string[];
|
|
117
|
+
source?: EngineOperationSource;
|
|
118
|
+
kind?: EngineOperationKind;
|
|
119
|
+
title?: string;
|
|
120
|
+
description?: string;
|
|
121
|
+
createsWorkspace?: boolean;
|
|
122
|
+
requiredHostMethods?: readonly WorkflowHostMethodRequirement[];
|
|
123
|
+
requiredHostCapabilities?: readonly WorkflowHostCapabilityRequirement[];
|
|
124
|
+
inputFields: readonly WorkflowInputFieldDefinition[];
|
|
125
|
+
cli?: EngineOperationCli;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export class EngineOperationValidationError extends Error {
|
|
129
|
+
readonly operation: string;
|
|
130
|
+
|
|
131
|
+
constructor(input: { operation: string; message: string; cause?: unknown }) {
|
|
132
|
+
super(input.message, { cause: input.cause });
|
|
133
|
+
this.name = "EngineOperationValidationError";
|
|
134
|
+
this.operation = input.operation;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class EngineOperationNotFoundError extends Error {
|
|
139
|
+
readonly operation: string;
|
|
140
|
+
|
|
141
|
+
constructor(operation: string) {
|
|
142
|
+
super(`Unknown operation ${operation}`);
|
|
143
|
+
this.name = "EngineOperationNotFoundError";
|
|
144
|
+
this.operation = operation;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type ProviderControllers = Record<string, WorkflowProviderController>;
|
|
149
|
+
|
|
150
|
+
type EvaluationMode = "plan" | "apply";
|
|
151
|
+
|
|
152
|
+
type EvaluationState = {
|
|
153
|
+
context: Record<string, JsonValue>;
|
|
154
|
+
upstreamRunIds: string[];
|
|
155
|
+
known: boolean;
|
|
156
|
+
blockedReason?: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type EvaluationResult = EvaluationState & {
|
|
160
|
+
planNodes: WorkflowPlanNode[];
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
type EvaluateNodeInput = {
|
|
164
|
+
workflow: LoadedWorkflow;
|
|
165
|
+
node: WorkflowNodeDefinition<any, any, any>;
|
|
166
|
+
providers: ProviderControllers;
|
|
167
|
+
providerFingerprint: string;
|
|
168
|
+
mode: EvaluationMode;
|
|
169
|
+
state: EvaluationState;
|
|
170
|
+
prefix: string[];
|
|
171
|
+
root: boolean;
|
|
172
|
+
suppressSequenceName?: string;
|
|
173
|
+
planNodes: WorkflowPlanNode[];
|
|
174
|
+
index: { value: number };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
type RuntimeOperationEntry = {
|
|
178
|
+
readonly summary: EngineOperationSummary;
|
|
179
|
+
readonly run: (input: { workflow?: string; input?: unknown }) => Promise<unknown>;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export class DevMachineEngine {
|
|
183
|
+
private readonly projectDir: string;
|
|
184
|
+
private readonly configPath: string;
|
|
185
|
+
private readonly statePath: string;
|
|
186
|
+
private state: StateService | undefined;
|
|
187
|
+
private providers: BaseProviderPlugin[];
|
|
188
|
+
private readonly providerFactory: ProviderFactory;
|
|
189
|
+
private readonly stateFactory: StateServiceFactory;
|
|
190
|
+
private readonly interactionPresenter: InteractionPresenter;
|
|
191
|
+
private readonly local: LocalWorkspaceRuntime;
|
|
192
|
+
private readonly handlers = new Set<EventHandler>();
|
|
193
|
+
private workflows = new Map<string, LoadedWorkflow>();
|
|
194
|
+
|
|
195
|
+
constructor(options: CreateDevMachineEngineOptions = {}) {
|
|
196
|
+
this.configPath = options.configPath
|
|
197
|
+
? resolve(options.configPath)
|
|
198
|
+
: join(resolve(options.projectDir ?? process.cwd()), "rig.config.ts");
|
|
199
|
+
this.projectDir = resolve(options.configPath ? dirname(this.configPath) : options.projectDir ?? process.cwd());
|
|
200
|
+
this.statePath = options.state?.path ?? (options.statePath ? resolve(options.statePath) : join(this.projectDir, ".rigkit", "state.sqlite"));
|
|
201
|
+
this.state = options.state;
|
|
202
|
+
this.providers = options.providers ?? [];
|
|
203
|
+
this.providerFactory = options.providerFactory ?? ((input) => this.createProviderFromPlugin(input));
|
|
204
|
+
this.stateFactory = options.stateFactory ?? createStateStore;
|
|
205
|
+
this.interactionPresenter = options.interaction?.present ?? defaultInteractionPresenter;
|
|
206
|
+
this.local = {
|
|
207
|
+
open: options.local?.open ?? openLocalTarget,
|
|
208
|
+
command: options.local?.command ?? runLocalCommand,
|
|
209
|
+
requestCapability: options.local?.requestCapability ?? requestUnsupportedHostCapability,
|
|
210
|
+
requestCapabilitySession: options.local?.requestCapabilitySession ?? requestUnsupportedHostCapabilitySession,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
onEvent(handler: EventHandler): () => void {
|
|
215
|
+
this.handlers.add(handler);
|
|
216
|
+
return () => this.handlers.delete(handler);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async load(): Promise<EngineLoadResult> {
|
|
220
|
+
loadDotEnv(this.projectDir);
|
|
221
|
+
|
|
222
|
+
if (!existsSync(this.configPath)) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`No Rigkit config found at ${this.configPath}. Create one with "rig init" or pass --config <file>.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const moduleUrl = pathToFileURL(this.configPath);
|
|
229
|
+
moduleUrl.searchParams.set("t", String(Date.now()));
|
|
230
|
+
const mod = await import(moduleUrl.href);
|
|
231
|
+
const roots = normalizeDefinitions(mod.default ?? mod.workflow);
|
|
232
|
+
const loaded = await Promise.all(roots.map((root) => this.resolveWorkflow(root)));
|
|
233
|
+
const workflow = loaded[0];
|
|
234
|
+
if (!workflow) {
|
|
235
|
+
throw new Error(`rig.config.ts must define at least one workflow`);
|
|
236
|
+
}
|
|
237
|
+
this.providers = mergeProviderPlugins([
|
|
238
|
+
...this.providers,
|
|
239
|
+
...roots.flatMap((root) => Object.values(root.workflow.providers as WorkflowProviderMap))
|
|
240
|
+
.map((provider) => provider.plugin)
|
|
241
|
+
.filter(isBaseProviderPlugin),
|
|
242
|
+
]);
|
|
243
|
+
this.state ??= this.stateFactory({
|
|
244
|
+
projectDir: this.projectDir,
|
|
245
|
+
configPath: this.configPath,
|
|
246
|
+
statePath: this.statePath,
|
|
247
|
+
});
|
|
248
|
+
await this.state.syncSchema();
|
|
249
|
+
|
|
250
|
+
this.workflows = new Map(loaded.map((item) => [item.name, item]));
|
|
251
|
+
if (this.workflows.size !== loaded.length) {
|
|
252
|
+
throw new Error(`Workflow names must be unique`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const item of loaded) {
|
|
256
|
+
this.emit({ type: "definition.loaded", workflow: item.name });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
workflow,
|
|
261
|
+
workflows: loaded,
|
|
262
|
+
projectDir: this.projectDir,
|
|
263
|
+
configPath: this.configPath,
|
|
264
|
+
statePath: this.getStateService().path,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
listWorkflows(): LoadedWorkflow[] {
|
|
269
|
+
return [...this.workflows.values()];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
listMachines(): LoadedWorkflow[] {
|
|
273
|
+
return this.listWorkflows();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
getProjectInfo(): EngineProjectInfo {
|
|
277
|
+
const workflows = this.listWorkflowSummaries();
|
|
278
|
+
return {
|
|
279
|
+
projectDir: this.projectDir,
|
|
280
|
+
configPath: this.configPath,
|
|
281
|
+
statePath: this.state?.path ?? this.statePath,
|
|
282
|
+
workflows,
|
|
283
|
+
workflow: workflows.length === 1 ? workflows[0] : undefined,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
listWorkflowSummaries(): WorkflowSummary[] {
|
|
288
|
+
return this.listWorkflows().map((workflow) => summarizeWorkflow(workflow));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
listWorkspaces(): WorkspaceRecord[] {
|
|
292
|
+
return this.getStateService().listWorkspaces();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
listSnapshots(): SnapshotRecord[] {
|
|
296
|
+
return this.getStateService().listSnapshots();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
listOperations(): EngineOperationSummary[] {
|
|
300
|
+
return this.listConfigOperationSummaries();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
listRuntimeOperations(): EngineOperationSummary[] {
|
|
304
|
+
return this.listRuntimeOperationEntries().map((entry) => entry.summary);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private listRuntimeOperationEntries(): RuntimeOperationEntry[] {
|
|
308
|
+
const configOperations = this.listConfigOperationEntries();
|
|
309
|
+
const configOperationIds = new Set(configOperations.map((entry) => entry.summary.id));
|
|
310
|
+
const coreOperations = this.listCoreOperationEntries();
|
|
311
|
+
return [
|
|
312
|
+
...coreOperations.filter((entry) =>
|
|
313
|
+
!configOperationIds.has(entry.summary.id) &&
|
|
314
|
+
!entry.summary.aliases?.some((alias) => configOperationIds.has(alias))
|
|
315
|
+
),
|
|
316
|
+
...configOperations,
|
|
317
|
+
];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private listConfigOperationEntries(): RuntimeOperationEntry[] {
|
|
321
|
+
return this.listConfigOperationSummaries().map((summary) => ({
|
|
322
|
+
summary,
|
|
323
|
+
run: async (input) =>
|
|
324
|
+
await this.runOperation({
|
|
325
|
+
operation: summary.id,
|
|
326
|
+
workflow: input.workflow,
|
|
327
|
+
input: input.input,
|
|
328
|
+
}),
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private listConfigOperationSummaries(): EngineOperationSummary[] {
|
|
333
|
+
return this.listWorkflows().flatMap((workflow) =>
|
|
334
|
+
workflow.operations.map((operation) => {
|
|
335
|
+
assertAllowedConfigOperationId(operation.id);
|
|
336
|
+
return {
|
|
337
|
+
workflow: workflow.name,
|
|
338
|
+
id: operation.id,
|
|
339
|
+
source: "config" as const,
|
|
340
|
+
title: operation.title,
|
|
341
|
+
description: operation.description,
|
|
342
|
+
createsWorkspace: operation.createsWorkspace,
|
|
343
|
+
requiredHostMethods: operation.requiredHostMethods,
|
|
344
|
+
requiredHostCapabilities: operation.requiredHostCapabilities,
|
|
345
|
+
inputFields: operation.input?.fields ?? [],
|
|
346
|
+
};
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private listCoreOperationEntries(): RuntimeOperationEntry[] {
|
|
352
|
+
const workflows = this.listWorkflows();
|
|
353
|
+
const hasWorkspaceCreator = workflows.some((workflow) => workflow.create || workflow.workspace);
|
|
354
|
+
const workflowField = stringField({
|
|
355
|
+
name: "workflow",
|
|
356
|
+
required: false,
|
|
357
|
+
});
|
|
358
|
+
const coreOperation = (
|
|
359
|
+
summary: EngineOperationSummary,
|
|
360
|
+
run: RuntimeOperationEntry["run"],
|
|
361
|
+
): RuntimeOperationEntry => ({ summary, run });
|
|
362
|
+
|
|
363
|
+
return [
|
|
364
|
+
coreOperation(
|
|
365
|
+
{
|
|
366
|
+
workflow: "",
|
|
367
|
+
id: "plan",
|
|
368
|
+
source: "core",
|
|
369
|
+
kind: "command",
|
|
370
|
+
title: "Plan",
|
|
371
|
+
description: "Show cached and pending steps",
|
|
372
|
+
inputFields: [workflowField],
|
|
373
|
+
cli: {
|
|
374
|
+
options: [
|
|
375
|
+
{ name: "workflow", flag: "--workflow" },
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
async (input) => {
|
|
380
|
+
const parsed = parseCoreOperationInput("plan", input.input);
|
|
381
|
+
return await this.plan({ workflow: optionalStringInput("plan", parsed, "workflow") });
|
|
382
|
+
},
|
|
383
|
+
),
|
|
384
|
+
coreOperation(
|
|
385
|
+
{
|
|
386
|
+
workflow: "",
|
|
387
|
+
id: "apply",
|
|
388
|
+
source: "core",
|
|
389
|
+
kind: "command",
|
|
390
|
+
title: "Apply",
|
|
391
|
+
description: "Resolve the workflow, running pending nodes",
|
|
392
|
+
inputFields: [
|
|
393
|
+
workflowField,
|
|
394
|
+
booleanField({ name: "dryRun", required: false, defaultValue: false }),
|
|
395
|
+
],
|
|
396
|
+
cli: {
|
|
397
|
+
options: [
|
|
398
|
+
{ name: "workflow", flag: "--workflow" },
|
|
399
|
+
{ name: "dryRun", flag: "--dry-run", type: "boolean" },
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
async (input) => {
|
|
404
|
+
const parsed = parseCoreOperationInput("apply", input.input);
|
|
405
|
+
const workflow = optionalStringInput("apply", parsed, "workflow");
|
|
406
|
+
const dryRun = optionalBooleanInput("apply", parsed, "dryRun", false);
|
|
407
|
+
return dryRun
|
|
408
|
+
? { dryRun: true, plan: await this.plan({ workflow }) }
|
|
409
|
+
: await this.apply({ workflow });
|
|
410
|
+
},
|
|
411
|
+
),
|
|
412
|
+
...(hasWorkspaceCreator
|
|
413
|
+
? [
|
|
414
|
+
coreOperation(
|
|
415
|
+
{
|
|
416
|
+
workflow: "",
|
|
417
|
+
id: "create",
|
|
418
|
+
aliases: ["fork"],
|
|
419
|
+
source: "core",
|
|
420
|
+
kind: "command",
|
|
421
|
+
title: "Create",
|
|
422
|
+
description: "Create a workspace from the resolved workflow artifact",
|
|
423
|
+
createsWorkspace: true,
|
|
424
|
+
inputFields: [
|
|
425
|
+
workflowField,
|
|
426
|
+
stringField({ name: "name", required: true }),
|
|
427
|
+
],
|
|
428
|
+
cli: {
|
|
429
|
+
options: [
|
|
430
|
+
{ name: "workflow", flag: "--workflow" },
|
|
431
|
+
{ name: "name", flag: "--name", required: true },
|
|
432
|
+
],
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
async (input) => {
|
|
436
|
+
const parsed = parseCoreOperationInput("create", input.input);
|
|
437
|
+
return await this.fork({
|
|
438
|
+
workflow: optionalStringInput("create", parsed, "workflow"),
|
|
439
|
+
name: requiredStringInput("create", parsed, "name"),
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
),
|
|
443
|
+
]
|
|
444
|
+
: []),
|
|
445
|
+
coreOperation(
|
|
446
|
+
{
|
|
447
|
+
workflow: "",
|
|
448
|
+
id: "ssh",
|
|
449
|
+
source: "core",
|
|
450
|
+
kind: "command",
|
|
451
|
+
title: "SSH",
|
|
452
|
+
description: "Get an SSH command for a workspace or VM",
|
|
453
|
+
requiredHostMethods: [{ id: "host.command.run", modes: ["interactive"] }],
|
|
454
|
+
inputFields: [
|
|
455
|
+
workflowField,
|
|
456
|
+
stringField({ name: "workspaceOrVmId", position: 0, required: true }),
|
|
457
|
+
stringField({ name: "user", required: false }),
|
|
458
|
+
booleanField({ name: "print", required: false, defaultValue: false }),
|
|
459
|
+
],
|
|
460
|
+
cli: {
|
|
461
|
+
positionals: [
|
|
462
|
+
{ name: "workspaceOrVmId", index: 0 },
|
|
463
|
+
],
|
|
464
|
+
options: [
|
|
465
|
+
{ name: "workflow", flag: "--workflow" },
|
|
466
|
+
{ name: "user", flag: "--user" },
|
|
467
|
+
{ name: "print", flag: "--print", type: "boolean" },
|
|
468
|
+
],
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
async (input) => {
|
|
472
|
+
const parsed = parseCoreOperationInput("ssh", input.input);
|
|
473
|
+
const workspaceOrVmId = requiredStringInput("ssh", parsed, "workspaceOrVmId");
|
|
474
|
+
const terminal = await this.attachTerminal({
|
|
475
|
+
workflow: optionalStringInput("ssh", parsed, "workflow"),
|
|
476
|
+
workspaceOrVmId,
|
|
477
|
+
printOnly: true,
|
|
478
|
+
user: optionalStringInput("ssh", parsed, "user"),
|
|
479
|
+
});
|
|
480
|
+
if (optionalBooleanInput("ssh", parsed, "print", false)) return terminal;
|
|
481
|
+
const commandResult = await (this.local.command ?? runLocalCommand)({
|
|
482
|
+
argv: ["sh", "-lc", terminal.command],
|
|
483
|
+
cwd: this.projectDir,
|
|
484
|
+
mode: "interactive",
|
|
485
|
+
reason: `Open an SSH session to ${workspaceOrVmId}`,
|
|
486
|
+
presentation: {
|
|
487
|
+
visible: true,
|
|
488
|
+
label: "SSH into workspace",
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
return { ...terminal, commandResult };
|
|
492
|
+
},
|
|
493
|
+
),
|
|
494
|
+
coreOperation(
|
|
495
|
+
{
|
|
496
|
+
workflow: "",
|
|
497
|
+
id: "snapshot",
|
|
498
|
+
source: "core",
|
|
499
|
+
kind: "command",
|
|
500
|
+
title: "Snapshot",
|
|
501
|
+
description: "Capture a snapshot from a workspace VM",
|
|
502
|
+
inputFields: [
|
|
503
|
+
workflowField,
|
|
504
|
+
stringField({ name: "workspace", position: 0, required: true }),
|
|
505
|
+
stringField({ name: "label", required: false }),
|
|
506
|
+
],
|
|
507
|
+
cli: {
|
|
508
|
+
positionals: [
|
|
509
|
+
{ name: "workspace", index: 0 },
|
|
510
|
+
],
|
|
511
|
+
options: [
|
|
512
|
+
{ name: "workflow", flag: "--workflow" },
|
|
513
|
+
{ name: "label", flag: "--label" },
|
|
514
|
+
],
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
async (input) => {
|
|
518
|
+
const parsed = parseCoreOperationInput("snapshot", input.input);
|
|
519
|
+
return await this.snapshotWorkspace({
|
|
520
|
+
workflow: optionalStringInput("snapshot", parsed, "workflow"),
|
|
521
|
+
workspace: requiredStringInput("snapshot", parsed, "workspace"),
|
|
522
|
+
label: optionalStringInput("snapshot", parsed, "label"),
|
|
523
|
+
});
|
|
524
|
+
},
|
|
525
|
+
),
|
|
526
|
+
coreOperation(
|
|
527
|
+
{
|
|
528
|
+
workflow: "",
|
|
529
|
+
id: "delete",
|
|
530
|
+
aliases: ["rm"],
|
|
531
|
+
source: "core",
|
|
532
|
+
kind: "command",
|
|
533
|
+
title: "Delete",
|
|
534
|
+
description: "Delete a workspace VM and remove it from state",
|
|
535
|
+
inputFields: [
|
|
536
|
+
workflowField,
|
|
537
|
+
stringField({ name: "workspace", position: 0, required: true }),
|
|
538
|
+
],
|
|
539
|
+
cli: {
|
|
540
|
+
positionals: [{ name: "workspace", index: 0 }],
|
|
541
|
+
options: [
|
|
542
|
+
{ name: "workflow", flag: "--workflow" },
|
|
543
|
+
{ name: "yes", flag: "--yes", aliases: ["-y"], required: true, type: "boolean", runtime: false },
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
async (input) => {
|
|
548
|
+
const parsed = parseCoreOperationInput("delete", input.input);
|
|
549
|
+
return await this.deleteWorkspace({
|
|
550
|
+
workflow: optionalStringInput("delete", parsed, "workflow"),
|
|
551
|
+
workspace: requiredStringInput("delete", parsed, "workspace"),
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
),
|
|
555
|
+
];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
listNodeRuns(): WorkflowNodeRunRecord[] {
|
|
559
|
+
return this.getStateService().listNodeRuns();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
hasOperation(operationId: string): boolean {
|
|
563
|
+
return this.listWorkflows().some((workflow) => workflow.operations.some((operation) => operation.id === operationId));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async runRuntimeOperation(input: { operation: string; workflow?: string; input?: unknown }): Promise<unknown> {
|
|
567
|
+
const operation = this.findRuntimeOperationEntry(input.operation);
|
|
568
|
+
if (!operation) throw new EngineOperationNotFoundError(input.operation);
|
|
569
|
+
return await operation.run({ workflow: input.workflow, input: input.input });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async runOperation(input: { operation: string; workflow?: string; input?: unknown }): Promise<unknown> {
|
|
573
|
+
const { workflow, operation } = this.getWorkflowOperation(input.operation, input.workflow);
|
|
574
|
+
const providers = await this.createProviders(workflow);
|
|
575
|
+
const metadata: JsonObject = {};
|
|
576
|
+
const runtime = await this.createTaskRuntime({
|
|
577
|
+
workflow,
|
|
578
|
+
providers,
|
|
579
|
+
nodePath: `operation.${operation.id}`,
|
|
580
|
+
metadata,
|
|
581
|
+
});
|
|
582
|
+
const operationInput = this.resolveOperationInput(workflow, operation, input.input ?? {});
|
|
583
|
+
const result = await operation.run({
|
|
584
|
+
...runtime,
|
|
585
|
+
input: Object.freeze(operationInput),
|
|
586
|
+
providers: runtime,
|
|
587
|
+
local: this.local,
|
|
588
|
+
workflow: workflow.name,
|
|
589
|
+
});
|
|
590
|
+
if (result !== undefined) assertJsonValue(result, `Operation ${operation.id} result`);
|
|
591
|
+
if (operation.createsWorkspace) return this.saveOperationWorkspace(workflow, operation, input.input, result);
|
|
592
|
+
return result ?? null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async plan(input: { workflow?: string; machine?: string } = {}): Promise<WorkflowPlan> {
|
|
596
|
+
const workflow = this.getWorkflow(input.workflow ?? input.machine);
|
|
597
|
+
const providers = await this.createProviders(workflow);
|
|
598
|
+
const result = await this.evaluate({
|
|
599
|
+
workflow,
|
|
600
|
+
providers,
|
|
601
|
+
mode: "plan",
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
this.emit({
|
|
605
|
+
type: "plan.created",
|
|
606
|
+
workflow: workflow.name,
|
|
607
|
+
cachedNodeCount: result.plan.cachedNodeCount,
|
|
608
|
+
nodeCount: result.plan.nodeCount,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
return result.plan;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async apply(input: { workflow?: string; machine?: string } = {}): Promise<{
|
|
615
|
+
context: Record<string, JsonValue>;
|
|
616
|
+
snapshotId?: string;
|
|
617
|
+
workspaceSource?: JsonValue;
|
|
618
|
+
plan: WorkflowPlan;
|
|
619
|
+
}> {
|
|
620
|
+
const workflow = this.getWorkflow(input.workflow ?? input.machine);
|
|
621
|
+
const providers = await this.createProviders(workflow);
|
|
622
|
+
const result = await this.evaluate({
|
|
623
|
+
workflow,
|
|
624
|
+
providers,
|
|
625
|
+
mode: "apply",
|
|
626
|
+
});
|
|
627
|
+
const workspaceSource = this.resolveWorkspaceSource(workflow, result.context, providers, { required: false });
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
context: result.context,
|
|
631
|
+
snapshotId: snapshotIdOf(workspaceSource),
|
|
632
|
+
workspaceSource,
|
|
633
|
+
plan: result.plan,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async fork(input: { workflow?: string; machine?: string; name: string }): Promise<WorkspaceRecord> {
|
|
638
|
+
if (!input.name) throw new Error(`fork requires a workspace name`);
|
|
639
|
+
|
|
640
|
+
const applied = await this.apply({ workflow: input.workflow ?? input.machine });
|
|
641
|
+
const workflow = this.getWorkflow(input.workflow ?? input.machine);
|
|
642
|
+
const providers = await this.createProviders(workflow);
|
|
643
|
+
if (workflow.create) {
|
|
644
|
+
return await this.createWorkspaceFromCallback({
|
|
645
|
+
workflow,
|
|
646
|
+
providers,
|
|
647
|
+
context: applied.context,
|
|
648
|
+
name: input.name,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const sourceRef = this.resolveWorkspaceSource(workflow, applied.context, providers, { required: true })!;
|
|
653
|
+
const workspaceProvider = this.findWorkspaceProvider(providers, sourceRef);
|
|
654
|
+
const created = await workspaceProvider.createWorkspace(sourceRef, { name: input.name });
|
|
655
|
+
const providerId = created.providerId ?? providerIdOf(sourceRef) ?? this.providerIdForWorkspaceProvider(providers, workspaceProvider);
|
|
656
|
+
const now = new Date().toISOString();
|
|
657
|
+
|
|
658
|
+
const workspace: WorkspaceRecord = {
|
|
659
|
+
id: crypto.randomUUID(),
|
|
660
|
+
name: input.name,
|
|
661
|
+
providerId,
|
|
662
|
+
workflow: workflow.name,
|
|
663
|
+
resourceId: created.resourceId,
|
|
664
|
+
snapshotId: created.snapshotId,
|
|
665
|
+
sourceRef: created.sourceRef ?? sourceRef,
|
|
666
|
+
context: { ...applied.context },
|
|
667
|
+
createdAt: now,
|
|
668
|
+
updatedAt: now,
|
|
669
|
+
metadata: created.metadata ?? {},
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
this.getStateService().saveWorkspace(workspace);
|
|
673
|
+
await this.runWorkspaceCreatedHook({
|
|
674
|
+
workflow,
|
|
675
|
+
providers,
|
|
676
|
+
workspaceProvider,
|
|
677
|
+
workspace,
|
|
678
|
+
context: applied.context,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
this.emit({
|
|
682
|
+
type: "workspace.ready",
|
|
683
|
+
workspaceId: input.name,
|
|
684
|
+
providerId: workspace.providerId,
|
|
685
|
+
resourceId: workspace.resourceId,
|
|
686
|
+
snapshotId: workspace.snapshotId,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
return workspace;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async attachTerminal(input: {
|
|
693
|
+
workspaceOrVmId: string;
|
|
694
|
+
workflow?: string;
|
|
695
|
+
machine?: string;
|
|
696
|
+
printOnly?: boolean;
|
|
697
|
+
user?: string;
|
|
698
|
+
}): Promise<{ command: string }> {
|
|
699
|
+
const workspace = this.getStateService().findWorkspace(input.workspaceOrVmId);
|
|
700
|
+
const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace?.workflow);
|
|
701
|
+
const providers = await this.createProviders(workflow);
|
|
702
|
+
const workspaceProvider = workspace
|
|
703
|
+
? this.workspaceProviderById(providers, workspace.providerId)
|
|
704
|
+
: this.singleWorkspaceProvider(providers);
|
|
705
|
+
if (workspace) {
|
|
706
|
+
await this.runWorkspaceOpenHook({
|
|
707
|
+
workflow,
|
|
708
|
+
providers,
|
|
709
|
+
workspaceProvider,
|
|
710
|
+
workspace,
|
|
711
|
+
context: workspace.context,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
const terminal = await workspaceProvider.ssh(workspace?.resourceId ?? input.workspaceOrVmId, { user: input.user });
|
|
715
|
+
|
|
716
|
+
if (!input.printOnly) {
|
|
717
|
+
const proc = Bun.spawn(["sh", "-lc", terminal.command], {
|
|
718
|
+
stdin: "inherit",
|
|
719
|
+
stdout: "inherit",
|
|
720
|
+
stderr: "inherit",
|
|
721
|
+
});
|
|
722
|
+
await proc.exited;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return { command: terminal.command };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async deleteWorkspace(input: { workspace: string; workflow?: string; machine?: string }): Promise<WorkspaceRecord> {
|
|
729
|
+
const workspace = this.getStateService().getWorkspace(input.workspace);
|
|
730
|
+
if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
|
|
731
|
+
|
|
732
|
+
const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace.workflow);
|
|
733
|
+
const providers = await this.createProviders(workflow);
|
|
734
|
+
const workspaceProvider = this.workspaceProviderById(providers, workspace.providerId);
|
|
735
|
+
await workspaceProvider.deleteWorkspace(workspace);
|
|
736
|
+
|
|
737
|
+
this.getStateService().deleteWorkspace(input.workspace);
|
|
738
|
+
|
|
739
|
+
return workspace;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async snapshotWorkspace(input: { workspace: string; label?: string; workflow?: string; machine?: string }): Promise<WorkflowNodeRunRecord> {
|
|
743
|
+
const workspace = this.getStateService().getWorkspace(input.workspace);
|
|
744
|
+
if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
|
|
745
|
+
|
|
746
|
+
const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace.workflow);
|
|
747
|
+
const providers = await this.createProviders(workflow);
|
|
748
|
+
const workspaceProvider = this.workspaceProviderById(providers, workspace.providerId);
|
|
749
|
+
const snapshot = await workspaceProvider.snapshotWorkspace(workspace);
|
|
750
|
+
const sourceRef = snapshot.sourceRef ?? workspace.sourceRef;
|
|
751
|
+
const providerFingerprint = providerFingerprintFor(workflow);
|
|
752
|
+
const now = new Date().toISOString();
|
|
753
|
+
const record: WorkflowNodeRunRecord = {
|
|
754
|
+
id: crypto.randomUUID(),
|
|
755
|
+
workflow: workflow.name,
|
|
756
|
+
nodePath: `workspace.${workspace.name}`,
|
|
757
|
+
nodeName: input.label ?? `workspace:${workspace.name}`,
|
|
758
|
+
nodeKind: "workspace-snapshot",
|
|
759
|
+
nodeKey: hash({
|
|
760
|
+
kind: "workspace-snapshot",
|
|
761
|
+
workspace: workspace.name,
|
|
762
|
+
label: input.label ?? null,
|
|
763
|
+
}),
|
|
764
|
+
providerFingerprint,
|
|
765
|
+
upstreamRunIds: [],
|
|
766
|
+
output: { sourceRef },
|
|
767
|
+
artifacts: collectArtifacts(sourceRef),
|
|
768
|
+
invalidated: false,
|
|
769
|
+
createdAt: now,
|
|
770
|
+
metadata: {
|
|
771
|
+
workspace: workspace.name,
|
|
772
|
+
label: input.label ?? null,
|
|
773
|
+
snapshotId: snapshot.snapshotId ?? null,
|
|
774
|
+
},
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
this.getStateService().saveNodeRun(record);
|
|
778
|
+
return record;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private async evaluate(input: {
|
|
782
|
+
workflow: LoadedWorkflow;
|
|
783
|
+
providers: ProviderControllers;
|
|
784
|
+
mode: EvaluationMode;
|
|
785
|
+
}): Promise<{ context: Record<string, JsonValue>; plan: WorkflowPlan }> {
|
|
786
|
+
const providerFingerprint = providerFingerprintFor(input.workflow);
|
|
787
|
+
const planNodes: WorkflowPlanNode[] = [];
|
|
788
|
+
const result = await this.evaluateNode({
|
|
789
|
+
workflow: input.workflow,
|
|
790
|
+
providers: input.providers,
|
|
791
|
+
providerFingerprint,
|
|
792
|
+
mode: input.mode,
|
|
793
|
+
node: input.workflow.root,
|
|
794
|
+
state: {
|
|
795
|
+
context: {},
|
|
796
|
+
upstreamRunIds: [],
|
|
797
|
+
known: true,
|
|
798
|
+
},
|
|
799
|
+
prefix: [],
|
|
800
|
+
root: true,
|
|
801
|
+
planNodes,
|
|
802
|
+
index: { value: 0 },
|
|
803
|
+
});
|
|
804
|
+
const cachedNodeCount = planNodes.filter((node) => node.status === "cached").length;
|
|
805
|
+
const plan: WorkflowPlan = {
|
|
806
|
+
workflow: input.workflow.name,
|
|
807
|
+
providerFingerprint,
|
|
808
|
+
cachedNodeCount,
|
|
809
|
+
nodeCount: planNodes.length,
|
|
810
|
+
nodes: planNodes,
|
|
811
|
+
finalContext: result.known ? result.context : undefined,
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
context: result.context,
|
|
816
|
+
plan,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private async evaluateNode(input: EvaluateNodeInput): Promise<EvaluationResult> {
|
|
821
|
+
if (input.node.nodeKind === "task") {
|
|
822
|
+
return await this.evaluateTask(input as EvaluateNodeInput & { node: WorkflowTaskNode<any, any, any> });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (input.node.nodeKind === "parallel") {
|
|
826
|
+
return await this.evaluateParallel(input);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const sequencePrefix = input.root || input.suppressSequenceName === input.node.name
|
|
830
|
+
? input.prefix
|
|
831
|
+
: [...input.prefix, input.node.name];
|
|
832
|
+
let state = input.state;
|
|
833
|
+
|
|
834
|
+
for (const child of sequenceChildren(input.node)) {
|
|
835
|
+
const result = await this.evaluateNode({
|
|
836
|
+
...input,
|
|
837
|
+
node: child,
|
|
838
|
+
state,
|
|
839
|
+
prefix: sequencePrefix,
|
|
840
|
+
root: false,
|
|
841
|
+
});
|
|
842
|
+
state = {
|
|
843
|
+
context: result.context,
|
|
844
|
+
upstreamRunIds: result.upstreamRunIds,
|
|
845
|
+
known: result.known,
|
|
846
|
+
blockedReason: result.blockedReason,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return { ...state, planNodes: input.planNodes };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private async evaluateParallel(input: EvaluateNodeInput): Promise<EvaluationResult> {
|
|
854
|
+
const branches = parallelBranches(input.node);
|
|
855
|
+
const branchOutputs: Record<string, JsonValue> = {};
|
|
856
|
+
const joinedRunIds: string[] = [];
|
|
857
|
+
let known = input.state.known;
|
|
858
|
+
let blockedReason = input.state.blockedReason;
|
|
859
|
+
|
|
860
|
+
for (const [branchName, branch] of Object.entries(branches)) {
|
|
861
|
+
if (branchName in input.state.context) {
|
|
862
|
+
throw new Error(`Parallel branch ${branchName} conflicts with an existing context key`);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const branchState = await this.evaluateNode({
|
|
866
|
+
...input,
|
|
867
|
+
node: branch,
|
|
868
|
+
state: {
|
|
869
|
+
context: { ...input.state.context },
|
|
870
|
+
upstreamRunIds: [...input.state.upstreamRunIds],
|
|
871
|
+
known: input.state.known,
|
|
872
|
+
blockedReason: input.state.blockedReason,
|
|
873
|
+
},
|
|
874
|
+
prefix: [...input.prefix, branchName],
|
|
875
|
+
root: false,
|
|
876
|
+
suppressSequenceName: branchName,
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
if (branchState.known) {
|
|
880
|
+
branchOutputs[branchName] = branchState.context;
|
|
881
|
+
joinedRunIds.push(...branchState.upstreamRunIds);
|
|
882
|
+
} else {
|
|
883
|
+
known = false;
|
|
884
|
+
blockedReason ??= branchState.blockedReason ?? `depends on ${branchName}`;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
context: known ? { ...input.state.context, ...branchOutputs } : { ...input.state.context },
|
|
890
|
+
upstreamRunIds: known ? joinedRunIds.sort() : [],
|
|
891
|
+
known,
|
|
892
|
+
blockedReason,
|
|
893
|
+
planNodes: input.planNodes,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private async evaluateTask(input: EvaluateNodeInput & { node: WorkflowTaskNode<any, any, any> }): Promise<EvaluationResult> {
|
|
898
|
+
const nodePath = [...input.prefix, input.node.name].join(".");
|
|
899
|
+
const upstreamRunIds = [...input.state.upstreamRunIds];
|
|
900
|
+
const nodeKey = hash({
|
|
901
|
+
kind: "task",
|
|
902
|
+
path: nodePath,
|
|
903
|
+
name: input.node.name,
|
|
904
|
+
version: input.node.options?.version ?? null,
|
|
905
|
+
});
|
|
906
|
+
const planIndex = input.index.value++;
|
|
907
|
+
|
|
908
|
+
if (!input.state.known) {
|
|
909
|
+
input.planNodes.push({
|
|
910
|
+
index: planIndex,
|
|
911
|
+
path: nodePath,
|
|
912
|
+
name: input.node.name,
|
|
913
|
+
status: "pending",
|
|
914
|
+
reason: input.state.blockedReason ?? "upstream output is pending",
|
|
915
|
+
upstreamRunIds,
|
|
916
|
+
});
|
|
917
|
+
return {
|
|
918
|
+
context: input.state.context,
|
|
919
|
+
upstreamRunIds: [],
|
|
920
|
+
known: false,
|
|
921
|
+
blockedReason: input.state.blockedReason ?? `depends on ${nodePath}`,
|
|
922
|
+
planNodes: input.planNodes,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const cached = await this.findReusableTaskRun({
|
|
927
|
+
workflow: input.workflow.name,
|
|
928
|
+
nodePath,
|
|
929
|
+
nodeKey,
|
|
930
|
+
providerFingerprint: input.providerFingerprint,
|
|
931
|
+
upstreamRunIds,
|
|
932
|
+
providers: input.providers,
|
|
933
|
+
outputSchema: input.node.options?.output,
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
if (cached) {
|
|
937
|
+
this.emit({ type: "node.cached", nodePath, runId: cached.id });
|
|
938
|
+
input.planNodes.push({
|
|
939
|
+
index: planIndex,
|
|
940
|
+
path: nodePath,
|
|
941
|
+
name: input.node.name,
|
|
942
|
+
status: "cached",
|
|
943
|
+
runId: cached.id,
|
|
944
|
+
upstreamRunIds,
|
|
945
|
+
});
|
|
946
|
+
return {
|
|
947
|
+
context: { ...input.state.context, ...cached.output },
|
|
948
|
+
upstreamRunIds: [cached.id],
|
|
949
|
+
known: true,
|
|
950
|
+
planNodes: input.planNodes,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
input.planNodes.push({
|
|
955
|
+
index: planIndex,
|
|
956
|
+
path: nodePath,
|
|
957
|
+
name: input.node.name,
|
|
958
|
+
status: "pending",
|
|
959
|
+
reason: "no reusable node run",
|
|
960
|
+
upstreamRunIds,
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
if (input.mode === "plan") {
|
|
964
|
+
return {
|
|
965
|
+
context: input.state.context,
|
|
966
|
+
upstreamRunIds: [],
|
|
967
|
+
known: false,
|
|
968
|
+
blockedReason: `depends on ${nodePath}`,
|
|
969
|
+
planNodes: input.planNodes,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
this.emit({ type: "node.started", nodePath });
|
|
974
|
+
const metadata: JsonObject = {};
|
|
975
|
+
const runtime = await this.createTaskRuntime({
|
|
976
|
+
workflow: input.workflow,
|
|
977
|
+
providers: input.providers,
|
|
978
|
+
nodePath,
|
|
979
|
+
metadata,
|
|
980
|
+
});
|
|
981
|
+
const result = await input.node.handler({
|
|
982
|
+
...runtime,
|
|
983
|
+
providers: runtime,
|
|
984
|
+
ctx: Object.freeze({ ...input.state.context }),
|
|
985
|
+
runtime: {
|
|
986
|
+
workflow: input.workflow.name,
|
|
987
|
+
nodePath,
|
|
988
|
+
metadata: (value) => {
|
|
989
|
+
Object.assign(metadata, value);
|
|
990
|
+
},
|
|
991
|
+
log: (data, options = {}) => {
|
|
992
|
+
this.emit({
|
|
993
|
+
type: "log.output",
|
|
994
|
+
nodePath,
|
|
995
|
+
stream: options.stream ?? "info",
|
|
996
|
+
label: options.label,
|
|
997
|
+
data,
|
|
998
|
+
});
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
const output = normalizeTaskOutput(nodePath, result, input.node.options?.output, "fresh");
|
|
1003
|
+
if (!output) {
|
|
1004
|
+
throw new Error(`Task ${nodePath} output failed schema validation`);
|
|
1005
|
+
}
|
|
1006
|
+
const artifacts = collectArtifacts(output);
|
|
1007
|
+
const record: WorkflowNodeRunRecord = {
|
|
1008
|
+
id: crypto.randomUUID(),
|
|
1009
|
+
workflow: input.workflow.name,
|
|
1010
|
+
nodePath,
|
|
1011
|
+
nodeName: input.node.name,
|
|
1012
|
+
nodeKind: input.node.nodeKind,
|
|
1013
|
+
nodeKey,
|
|
1014
|
+
providerFingerprint: input.providerFingerprint,
|
|
1015
|
+
upstreamRunIds,
|
|
1016
|
+
output,
|
|
1017
|
+
artifacts,
|
|
1018
|
+
invalidated: false,
|
|
1019
|
+
createdAt: new Date().toISOString(),
|
|
1020
|
+
metadata,
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
this.getStateService().saveNodeRun(record);
|
|
1024
|
+
for (const artifact of artifacts) {
|
|
1025
|
+
const providerId = providerIdOf(artifact);
|
|
1026
|
+
this.emit({
|
|
1027
|
+
type: "artifact.created",
|
|
1028
|
+
nodePath,
|
|
1029
|
+
providerId: providerId ?? "unknown",
|
|
1030
|
+
kind: kindOf(artifact) ?? "artifact",
|
|
1031
|
+
ref: artifact,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
this.emit({ type: "node.completed", nodePath, runId: record.id });
|
|
1035
|
+
|
|
1036
|
+
return {
|
|
1037
|
+
context: { ...input.state.context, ...output },
|
|
1038
|
+
upstreamRunIds: [record.id],
|
|
1039
|
+
known: true,
|
|
1040
|
+
planNodes: input.planNodes,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private async findReusableTaskRun(input: {
|
|
1045
|
+
workflow: string;
|
|
1046
|
+
nodePath: string;
|
|
1047
|
+
nodeKey: string;
|
|
1048
|
+
providerFingerprint: string;
|
|
1049
|
+
upstreamRunIds: readonly string[];
|
|
1050
|
+
providers: ProviderControllers;
|
|
1051
|
+
outputSchema?: OutputSchema;
|
|
1052
|
+
}): Promise<WorkflowNodeRunRecord | undefined> {
|
|
1053
|
+
const cached = this.getStateService().findReusableNodeRun(input);
|
|
1054
|
+
if (!cached) return undefined;
|
|
1055
|
+
|
|
1056
|
+
const parsed = normalizeTaskOutput(input.nodePath, cached.output, input.outputSchema, "cached");
|
|
1057
|
+
if (!parsed) return undefined;
|
|
1058
|
+
|
|
1059
|
+
for (const artifact of cached.artifacts) {
|
|
1060
|
+
const providerId = providerIdOf(artifact);
|
|
1061
|
+
if (!providerId) return undefined;
|
|
1062
|
+
const provider = Object.values(input.providers).find((controller) => controller.providerId === providerId);
|
|
1063
|
+
if (provider?.validateArtifact && !await provider.validateArtifact(artifact)) return undefined;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return {
|
|
1067
|
+
...cached,
|
|
1068
|
+
output: parsed,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private async createTaskRuntime(input: {
|
|
1073
|
+
workflow: LoadedWorkflow;
|
|
1074
|
+
providers: ProviderControllers;
|
|
1075
|
+
nodePath: string;
|
|
1076
|
+
metadata: JsonObject;
|
|
1077
|
+
}): Promise<ProviderRuntimeMap<WorkflowProviderMap>> {
|
|
1078
|
+
const entries = await Promise.all(
|
|
1079
|
+
Object.entries(input.providers).map(async ([name, provider]) => {
|
|
1080
|
+
const runtimeContext: ProviderRuntimeContext = {
|
|
1081
|
+
workflow: input.workflow.name,
|
|
1082
|
+
nodePath: input.nodePath,
|
|
1083
|
+
emit: (event) => this.emit(event),
|
|
1084
|
+
interaction: {
|
|
1085
|
+
present: async (session) => {
|
|
1086
|
+
const interactionId = session.id ?? crypto.randomUUID();
|
|
1087
|
+
this.emit({
|
|
1088
|
+
type: "interaction.awaiting_user",
|
|
1089
|
+
nodePath: input.nodePath,
|
|
1090
|
+
interactionId,
|
|
1091
|
+
label: session.title,
|
|
1092
|
+
title: session.title,
|
|
1093
|
+
url: session.url,
|
|
1094
|
+
instructions: session.instructions,
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
try {
|
|
1098
|
+
await this.interactionPresenter({
|
|
1099
|
+
id: interactionId,
|
|
1100
|
+
nodePath: input.nodePath,
|
|
1101
|
+
title: session.title,
|
|
1102
|
+
url: session.url,
|
|
1103
|
+
instructions: session.instructions,
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
const result = await session.completed;
|
|
1107
|
+
this.emit({
|
|
1108
|
+
type: "interaction.completed",
|
|
1109
|
+
nodePath: input.nodePath,
|
|
1110
|
+
interactionId,
|
|
1111
|
+
label: session.title,
|
|
1112
|
+
title: session.title,
|
|
1113
|
+
});
|
|
1114
|
+
return result;
|
|
1115
|
+
} finally {
|
|
1116
|
+
await session.stop();
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
local: this.local,
|
|
1121
|
+
metadata: (metadata) => {
|
|
1122
|
+
Object.assign(input.metadata, metadata);
|
|
1123
|
+
},
|
|
1124
|
+
};
|
|
1125
|
+
return [name, await provider.runtime(runtimeContext)] as const;
|
|
1126
|
+
}),
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
return Object.fromEntries(entries) as ProviderRuntimeMap<WorkflowProviderMap>;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
private async runWorkspaceCreatedHook(input: {
|
|
1133
|
+
workflow: LoadedWorkflow;
|
|
1134
|
+
providers: ProviderControllers;
|
|
1135
|
+
workspaceProvider: WorkflowWorkspaceProvider;
|
|
1136
|
+
workspace: WorkspaceRecord;
|
|
1137
|
+
context: Record<string, JsonValue>;
|
|
1138
|
+
}): Promise<void> {
|
|
1139
|
+
await this.runWorkspaceHook("created", input.workflow.workspace?.onCreated, input);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private async runWorkspaceOpenHook(input: {
|
|
1143
|
+
workflow: LoadedWorkflow;
|
|
1144
|
+
providers: ProviderControllers;
|
|
1145
|
+
workspaceProvider: WorkflowWorkspaceProvider;
|
|
1146
|
+
workspace: WorkspaceRecord;
|
|
1147
|
+
context: Record<string, JsonValue>;
|
|
1148
|
+
}): Promise<void> {
|
|
1149
|
+
await this.runWorkspaceHook("open", input.workflow.workspace?.onOpen, input);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private async runWorkspaceHook(
|
|
1153
|
+
lifecycle: "created" | "open",
|
|
1154
|
+
hook: ((context: {
|
|
1155
|
+
workspace: WorkspaceRuntimeRecord;
|
|
1156
|
+
ctx: Readonly<Record<string, JsonValue>>;
|
|
1157
|
+
providers: ProviderRuntimeMap<WorkflowProviderMap>;
|
|
1158
|
+
providerContext: ProviderWorkspaceContext;
|
|
1159
|
+
local: LocalWorkspaceRuntime;
|
|
1160
|
+
}) => Promise<void> | void) | undefined,
|
|
1161
|
+
input: {
|
|
1162
|
+
workflow: LoadedWorkflow;
|
|
1163
|
+
providers: ProviderControllers;
|
|
1164
|
+
workspaceProvider: WorkflowWorkspaceProvider;
|
|
1165
|
+
workspace: WorkspaceRecord;
|
|
1166
|
+
context: Record<string, JsonValue>;
|
|
1167
|
+
},
|
|
1168
|
+
): Promise<void> {
|
|
1169
|
+
if (!hook) return;
|
|
1170
|
+
|
|
1171
|
+
const providerContext = await input.workspaceProvider.workspaceContext?.(input.workspace) ?? {};
|
|
1172
|
+
const workspace: WorkspaceRuntimeRecord = {
|
|
1173
|
+
...input.workspace,
|
|
1174
|
+
cwd: resolveWorkspaceCwd(input.workflow, input.context),
|
|
1175
|
+
};
|
|
1176
|
+
const metadata: JsonObject = {};
|
|
1177
|
+
const providers = await this.createTaskRuntime({
|
|
1178
|
+
workflow: input.workflow,
|
|
1179
|
+
providers: input.providers,
|
|
1180
|
+
nodePath: `workspace.${input.workspace.name}.${lifecycle}`,
|
|
1181
|
+
metadata,
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
await hook({
|
|
1185
|
+
workspace,
|
|
1186
|
+
ctx: Object.freeze({ ...input.context }),
|
|
1187
|
+
providers,
|
|
1188
|
+
providerContext: normalizeProviderWorkspaceContext(providerContext),
|
|
1189
|
+
local: this.local,
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private getWorkflow(name: string | undefined): LoadedWorkflow {
|
|
1194
|
+
if (this.workflows.size === 0) {
|
|
1195
|
+
throw new Error(`No workflows loaded. Call engine.load() first.`);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (name) {
|
|
1199
|
+
const workflow = this.workflows.get(name);
|
|
1200
|
+
if (!workflow) throw new Error(`Unknown workflow ${name}`);
|
|
1201
|
+
return workflow;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if (this.workflows.size === 1) return [...this.workflows.values()][0]!;
|
|
1205
|
+
|
|
1206
|
+
throw new Error(`Multiple workflows are defined; pass a workflow name`);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
private findRuntimeOperationEntry(operationId: string): RuntimeOperationEntry | undefined {
|
|
1210
|
+
return this.listRuntimeOperationEntries().find((entry) =>
|
|
1211
|
+
entry.summary.id === operationId || entry.summary.aliases?.includes(operationId)
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
private getWorkflowOperation(operationId: string, workflowName: string | undefined): {
|
|
1216
|
+
workflow: LoadedWorkflow;
|
|
1217
|
+
operation: WorkflowOperationDefinition<any, any>;
|
|
1218
|
+
} {
|
|
1219
|
+
const workflows = workflowName ? [this.getWorkflow(workflowName)] : this.listWorkflows();
|
|
1220
|
+
const matches = workflows.flatMap((workflow) =>
|
|
1221
|
+
workflow.operations
|
|
1222
|
+
.filter((operation) => operation.id === operationId)
|
|
1223
|
+
.map((operation) => ({ workflow, operation })),
|
|
1224
|
+
);
|
|
1225
|
+
if (matches.length === 1) return matches[0]!;
|
|
1226
|
+
if (matches.length > 1) throw new Error(`Multiple workflows define operation ${operationId}; pass a workflow name`);
|
|
1227
|
+
throw new EngineOperationNotFoundError(operationId);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
private resolveOperationInput(
|
|
1231
|
+
workflow: LoadedWorkflow,
|
|
1232
|
+
operation: WorkflowOperationDefinition<any, any>,
|
|
1233
|
+
value: unknown,
|
|
1234
|
+
): Record<string, unknown> {
|
|
1235
|
+
const raw = isPlainObject(value) ? value : {};
|
|
1236
|
+
const fields = operation.input?.fields ?? [];
|
|
1237
|
+
if (fields.length === 0) return { ...raw };
|
|
1238
|
+
|
|
1239
|
+
const resolved: Record<string, unknown> = {};
|
|
1240
|
+
for (const field of fields) {
|
|
1241
|
+
const rawValue = raw[field.name] ?? field.defaultValue;
|
|
1242
|
+
if (rawValue === undefined || rawValue === null || rawValue === "") {
|
|
1243
|
+
if (field.required ?? true) {
|
|
1244
|
+
throw new EngineOperationValidationError({
|
|
1245
|
+
operation: operation.id,
|
|
1246
|
+
message: `Operation ${operation.id} requires ${field.name}`,
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (field.kind === "workspace") {
|
|
1253
|
+
if (typeof rawValue !== "string") {
|
|
1254
|
+
throw new EngineOperationValidationError({
|
|
1255
|
+
operation: operation.id,
|
|
1256
|
+
message: `Operation ${operation.id} input ${field.name} must be a workspace name`,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
const workspace = this.getStateService().findWorkspace(rawValue);
|
|
1260
|
+
if (!workspace) {
|
|
1261
|
+
throw new EngineOperationValidationError({
|
|
1262
|
+
operation: operation.id,
|
|
1263
|
+
message: `Unknown workspace ${rawValue}`,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
if (workspace.workflow !== workflow.name) {
|
|
1267
|
+
throw new EngineOperationValidationError({
|
|
1268
|
+
operation: operation.id,
|
|
1269
|
+
message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
resolved[field.name] = {
|
|
1273
|
+
...workspace,
|
|
1274
|
+
cwd: resolveWorkspaceCwd(workflow, workspace.context),
|
|
1275
|
+
data: workspace.metadata,
|
|
1276
|
+
};
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (field.kind === "string") {
|
|
1281
|
+
if (typeof rawValue !== "string") {
|
|
1282
|
+
throw new EngineOperationValidationError({
|
|
1283
|
+
operation: operation.id,
|
|
1284
|
+
message: `Operation ${operation.id} input ${field.name} must be a string`,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
resolved[field.name] = rawValue;
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (field.kind === "boolean") {
|
|
1292
|
+
if (typeof rawValue !== "boolean") {
|
|
1293
|
+
throw new EngineOperationValidationError({
|
|
1294
|
+
operation: operation.id,
|
|
1295
|
+
message: `Operation ${operation.id} input ${field.name} must be a boolean`,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
resolved[field.name] = rawValue;
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if (field.kind === "number") {
|
|
1303
|
+
if (typeof rawValue !== "number" || !Number.isFinite(rawValue)) {
|
|
1304
|
+
throw new EngineOperationValidationError({
|
|
1305
|
+
operation: operation.id,
|
|
1306
|
+
message: `Operation ${operation.id} input ${field.name} must be a number`,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
resolved[field.name] = rawValue;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
return resolved;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private saveOperationWorkspace(
|
|
1317
|
+
workflow: LoadedWorkflow,
|
|
1318
|
+
operation: WorkflowOperationDefinition<any, any>,
|
|
1319
|
+
rawInput: unknown,
|
|
1320
|
+
result: unknown,
|
|
1321
|
+
): WorkspaceRecord {
|
|
1322
|
+
if (!isPlainObject(result)) {
|
|
1323
|
+
throw new Error(`Operation ${operation.id} must return an object when createsWorkspace is true`);
|
|
1324
|
+
}
|
|
1325
|
+
const data = result as Record<string, JsonValue>;
|
|
1326
|
+
const raw = isPlainObject(rawInput) ? rawInput : {};
|
|
1327
|
+
const name = typeof data.name === "string"
|
|
1328
|
+
? data.name
|
|
1329
|
+
: typeof raw.name === "string"
|
|
1330
|
+
? raw.name
|
|
1331
|
+
: undefined;
|
|
1332
|
+
if (!name) throw new Error(`Operation ${operation.id} must return or receive a workspace name`);
|
|
1333
|
+
const resourceId = typeof data.resourceId === "string"
|
|
1334
|
+
? data.resourceId
|
|
1335
|
+
: typeof data.vmId === "string"
|
|
1336
|
+
? data.vmId
|
|
1337
|
+
: name;
|
|
1338
|
+
const now = new Date().toISOString();
|
|
1339
|
+
const workspace: WorkspaceRecord = {
|
|
1340
|
+
id: crypto.randomUUID(),
|
|
1341
|
+
name,
|
|
1342
|
+
providerId: typeof data.providerId === "string" ? data.providerId : "config",
|
|
1343
|
+
workflow: workflow.name,
|
|
1344
|
+
resourceId,
|
|
1345
|
+
snapshotId: typeof data.snapshotId === "string" ? data.snapshotId : undefined,
|
|
1346
|
+
sourceRef: isJsonValue(data.sourceRef) ? data.sourceRef : data,
|
|
1347
|
+
context: {},
|
|
1348
|
+
createdAt: now,
|
|
1349
|
+
updatedAt: now,
|
|
1350
|
+
metadata: data,
|
|
1351
|
+
};
|
|
1352
|
+
this.getStateService().saveWorkspace(workspace);
|
|
1353
|
+
this.emit({
|
|
1354
|
+
type: "workspace.ready",
|
|
1355
|
+
workspaceId: workspace.name,
|
|
1356
|
+
providerId: workspace.providerId,
|
|
1357
|
+
resourceId: workspace.resourceId,
|
|
1358
|
+
snapshotId: workspace.snapshotId,
|
|
1359
|
+
});
|
|
1360
|
+
return workspace;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
private async createWorkspaceFromCallback(input: {
|
|
1364
|
+
workflow: LoadedWorkflow;
|
|
1365
|
+
providers: ProviderControllers;
|
|
1366
|
+
context: Record<string, JsonValue>;
|
|
1367
|
+
name: string;
|
|
1368
|
+
}): Promise<WorkspaceRecord> {
|
|
1369
|
+
if (!input.workflow.create) {
|
|
1370
|
+
throw new Error(`Workflow ${input.workflow.name} does not define a create callback`);
|
|
1371
|
+
}
|
|
1372
|
+
const metadata: JsonObject = {};
|
|
1373
|
+
const runtime = await this.createTaskRuntime({
|
|
1374
|
+
workflow: input.workflow,
|
|
1375
|
+
providers: input.providers,
|
|
1376
|
+
nodePath: `create.${input.name}`,
|
|
1377
|
+
metadata,
|
|
1378
|
+
});
|
|
1379
|
+
const result = await input.workflow.create.handler({
|
|
1380
|
+
...runtime,
|
|
1381
|
+
ctx: Object.freeze({ ...input.context }),
|
|
1382
|
+
name: input.name,
|
|
1383
|
+
providers: runtime,
|
|
1384
|
+
local: this.local,
|
|
1385
|
+
workflow: input.workflow.name,
|
|
1386
|
+
});
|
|
1387
|
+
assertJsonValue(result, `Workflow ${input.workflow.name} create result`);
|
|
1388
|
+
if (!isPlainObject(result)) {
|
|
1389
|
+
throw new Error(`Workflow ${input.workflow.name} create result must be an object`);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const data = result as Record<string, JsonValue>;
|
|
1393
|
+
const name = typeof data.name === "string" ? data.name : input.name;
|
|
1394
|
+
const resourceId = typeof data.resourceId === "string"
|
|
1395
|
+
? data.resourceId
|
|
1396
|
+
: typeof data.vmId === "string"
|
|
1397
|
+
? data.vmId
|
|
1398
|
+
: name;
|
|
1399
|
+
const now = new Date().toISOString();
|
|
1400
|
+
const workspace: WorkspaceRecord = {
|
|
1401
|
+
id: crypto.randomUUID(),
|
|
1402
|
+
name,
|
|
1403
|
+
providerId: typeof data.providerId === "string" ? data.providerId : "config",
|
|
1404
|
+
workflow: input.workflow.name,
|
|
1405
|
+
resourceId,
|
|
1406
|
+
snapshotId: typeof data.snapshotId === "string" ? data.snapshotId : undefined,
|
|
1407
|
+
sourceRef: isJsonValue(data.sourceRef) ? data.sourceRef : data,
|
|
1408
|
+
context: { ...input.context },
|
|
1409
|
+
createdAt: now,
|
|
1410
|
+
updatedAt: now,
|
|
1411
|
+
metadata: data,
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
this.getStateService().saveWorkspace(workspace);
|
|
1415
|
+
this.emit({
|
|
1416
|
+
type: "workspace.ready",
|
|
1417
|
+
workspaceId: workspace.name,
|
|
1418
|
+
providerId: workspace.providerId,
|
|
1419
|
+
resourceId: workspace.resourceId,
|
|
1420
|
+
snapshotId: workspace.snapshotId,
|
|
1421
|
+
});
|
|
1422
|
+
return workspace;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
private getStateService(): StateService {
|
|
1426
|
+
if (!this.state) {
|
|
1427
|
+
throw new Error(`No state database loaded. Call engine.load() first.`);
|
|
1428
|
+
}
|
|
1429
|
+
return this.state;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
private async createProviders(workflow: LoadedWorkflow): Promise<ProviderControllers> {
|
|
1433
|
+
const entries = await Promise.all(
|
|
1434
|
+
Object.entries(workflow.providers).map(async ([name, provider]) => {
|
|
1435
|
+
const controller = await this.providerFactory({
|
|
1436
|
+
provider,
|
|
1437
|
+
storage: this.getStateService().providerStorage(provider.providerId),
|
|
1438
|
+
});
|
|
1439
|
+
return [name, controller] as const;
|
|
1440
|
+
}),
|
|
1441
|
+
);
|
|
1442
|
+
return Object.fromEntries(entries);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
private async createProviderFromPlugin(input: Parameters<ProviderFactory>[0]): Promise<WorkflowProviderController> {
|
|
1446
|
+
const plugin = this.providers.find((provider) => provider.providerId === input.provider.providerId);
|
|
1447
|
+
if (!plugin) {
|
|
1448
|
+
throw new Error(
|
|
1449
|
+
`Provider ${input.provider.providerId} does not implement the Rigkit workflow provider contract. ` +
|
|
1450
|
+
`Register a provider plugin to use it in workflow tasks.`,
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
return await plugin.createProvider(input);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
private async resolveWorkflow(root: WorkflowNodeDefinition<any, any, any>): Promise<LoadedWorkflow> {
|
|
1457
|
+
const providers: Record<string, LoadedProviderDefinition> = {};
|
|
1458
|
+
for (const [name, definition] of Object.entries(root.workflow.providers)) {
|
|
1459
|
+
if (!isProviderDefinition(definition)) {
|
|
1460
|
+
throw new Error(`Workflow ${root.workflow.name} provider ${name} is invalid`);
|
|
1461
|
+
}
|
|
1462
|
+
providers[name] = await resolveProviderDefinition(definition);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
return {
|
|
1466
|
+
name: root.workflow.name,
|
|
1467
|
+
providers,
|
|
1468
|
+
root,
|
|
1469
|
+
workspace: root.workspaceDefinition,
|
|
1470
|
+
create: root.createDefinition,
|
|
1471
|
+
operations: root.operations ?? [],
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
private resolveWorkspaceSource(
|
|
1476
|
+
workflow: LoadedWorkflow,
|
|
1477
|
+
context: Record<string, JsonValue>,
|
|
1478
|
+
providers: ProviderControllers,
|
|
1479
|
+
options: { required: boolean },
|
|
1480
|
+
): JsonValue | undefined {
|
|
1481
|
+
const source = workflow.workspace?.source?.(context);
|
|
1482
|
+
if (source !== undefined) {
|
|
1483
|
+
assertJsonValue(source, `Workflow ${workflow.name} workspace source`);
|
|
1484
|
+
return source;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const candidates = collectArtifacts(context).filter((artifact) =>
|
|
1488
|
+
Object.values(providers).some((provider) => provider.workspace?.canUse(artifact)),
|
|
1489
|
+
);
|
|
1490
|
+
|
|
1491
|
+
if (candidates.length === 1) return candidates[0];
|
|
1492
|
+
if (!options.required && candidates.length === 0) return undefined;
|
|
1493
|
+
if (candidates.length === 0) {
|
|
1494
|
+
throw new Error(`Workflow ${workflow.name} did not produce a provider artifact that can be forked`);
|
|
1495
|
+
}
|
|
1496
|
+
throw new Error(`Workflow ${workflow.name} produced multiple forkable artifacts; configure workspace.source`);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
private findWorkspaceProvider(
|
|
1500
|
+
providers: ProviderControllers,
|
|
1501
|
+
sourceRef: JsonValue,
|
|
1502
|
+
): WorkflowWorkspaceProvider {
|
|
1503
|
+
const provider = Object.values(providers).find((controller) => controller.workspace?.canUse(sourceRef));
|
|
1504
|
+
if (!provider?.workspace) {
|
|
1505
|
+
throw new Error(`No workflow provider can create a workspace from ${stableJson(sourceRef)}`);
|
|
1506
|
+
}
|
|
1507
|
+
return provider.workspace;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
private workspaceProviderById(
|
|
1511
|
+
providers: ProviderControllers,
|
|
1512
|
+
providerId: string,
|
|
1513
|
+
): WorkflowWorkspaceProvider {
|
|
1514
|
+
const provider = Object.values(providers).find((controller) => controller.providerId === providerId);
|
|
1515
|
+
if (!provider?.workspace) {
|
|
1516
|
+
throw new Error(`Provider ${providerId} does not support workspaces`);
|
|
1517
|
+
}
|
|
1518
|
+
return provider.workspace;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
private singleWorkspaceProvider(providers: ProviderControllers): WorkflowWorkspaceProvider {
|
|
1522
|
+
const workspaceProviders = Object.values(providers).filter((provider) => provider.workspace);
|
|
1523
|
+
if (workspaceProviders.length !== 1 || !workspaceProviders[0]?.workspace) {
|
|
1524
|
+
throw new Error(`Expected exactly one workspace-capable provider`);
|
|
1525
|
+
}
|
|
1526
|
+
return workspaceProviders[0].workspace;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
private providerIdForWorkspaceProvider(
|
|
1530
|
+
providers: ProviderControllers,
|
|
1531
|
+
workspaceProvider: WorkflowWorkspaceProvider,
|
|
1532
|
+
): string {
|
|
1533
|
+
for (const provider of Object.values(providers)) {
|
|
1534
|
+
if (provider.workspace === workspaceProvider) return provider.providerId;
|
|
1535
|
+
}
|
|
1536
|
+
return "unknown";
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
private emit(event: WorkflowEvent): void {
|
|
1540
|
+
for (const handler of this.handlers) handler(event);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
export async function createDevMachineEngine(
|
|
1545
|
+
options: CreateDevMachineEngineOptions = {},
|
|
1546
|
+
): Promise<DevMachineEngine> {
|
|
1547
|
+
return new DevMachineEngine(options);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function resolveProviderDefinition(
|
|
1551
|
+
definition: WorkflowDefinition<any, any>["providers"][string],
|
|
1552
|
+
): Promise<LoadedProviderDefinition> {
|
|
1553
|
+
return {
|
|
1554
|
+
providerId: definition.providerId,
|
|
1555
|
+
config: await resolveConfigObject(definition.config),
|
|
1556
|
+
plugin: definition.plugin,
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
async function resolveConfigObject(value: unknown): Promise<Record<string, unknown>> {
|
|
1561
|
+
const resolved = await resolveConfigValue(value);
|
|
1562
|
+
if (!resolved || typeof resolved !== "object" || Array.isArray(resolved)) {
|
|
1563
|
+
throw new Error(`Provider config must resolve to an object`);
|
|
1564
|
+
}
|
|
1565
|
+
return resolved as Record<string, unknown>;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
async function resolveConfigValue(value: unknown): Promise<unknown> {
|
|
1569
|
+
if (typeof value === "function") {
|
|
1570
|
+
return await (value as () => unknown)();
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (Array.isArray(value)) {
|
|
1574
|
+
return await Promise.all(value.map((item) => resolveConfigValue(item)));
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
if (value && typeof value === "object") {
|
|
1578
|
+
const entries = await Promise.all(
|
|
1579
|
+
Object.entries(value).map(async ([key, entry]) => [key, await resolveConfigValue(entry)] as const),
|
|
1580
|
+
);
|
|
1581
|
+
return Object.fromEntries(entries);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
return value;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function normalizeDefinitions(value: unknown): WorkflowNodeDefinition<any, any, any>[] {
|
|
1588
|
+
if (isRigkitConfig(value)) {
|
|
1589
|
+
return Object.entries(value.workflows).map(([name, node]) =>
|
|
1590
|
+
attachWorkflowProviders(name, node, value.providers)
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if (Array.isArray(value)) {
|
|
1595
|
+
throw new Error(`rig.config.ts must default export a workflow node or defineConfig(...)`);
|
|
1596
|
+
}
|
|
1597
|
+
if (!isWorkflowNode(value)) {
|
|
1598
|
+
throw new Error(`rig.config.ts must default export a node created with workflow(...).sequence(...) or defineConfig(...)`);
|
|
1599
|
+
}
|
|
1600
|
+
return [value];
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function attachWorkflowProviders(
|
|
1604
|
+
name: string,
|
|
1605
|
+
node: WorkflowNodeDefinition<any, any, any>,
|
|
1606
|
+
providers: WorkflowProviderMap,
|
|
1607
|
+
): WorkflowNodeDefinition<any, any, any> {
|
|
1608
|
+
const workflow: WorkflowDefinition<string, any> = {
|
|
1609
|
+
...node.workflow,
|
|
1610
|
+
name: node.workflow.name || name,
|
|
1611
|
+
providers,
|
|
1612
|
+
};
|
|
1613
|
+
return attachWorkflow(node, workflow);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function attachWorkflow(
|
|
1617
|
+
node: WorkflowNodeDefinition<any, any, any>,
|
|
1618
|
+
workflow: WorkflowDefinition<string, any>,
|
|
1619
|
+
): WorkflowNodeDefinition<any, any, any> {
|
|
1620
|
+
if (node.nodeKind === "parallel") {
|
|
1621
|
+
return {
|
|
1622
|
+
...node,
|
|
1623
|
+
workflow,
|
|
1624
|
+
branches: Object.fromEntries(
|
|
1625
|
+
Object.entries(parallelBranches(node)).map(([name, branch]) => [name, attachWorkflow(branch, workflow)]),
|
|
1626
|
+
),
|
|
1627
|
+
} as WorkflowNodeDefinition<any, any, any>;
|
|
1628
|
+
}
|
|
1629
|
+
if (node.nodeKind === "sequence") {
|
|
1630
|
+
return {
|
|
1631
|
+
...node,
|
|
1632
|
+
workflow,
|
|
1633
|
+
children: sequenceChildren(node).map((child) => attachWorkflow(child, workflow)),
|
|
1634
|
+
} as WorkflowNodeDefinition<any, any, any>;
|
|
1635
|
+
}
|
|
1636
|
+
return {
|
|
1637
|
+
...node,
|
|
1638
|
+
workflow,
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function summarizeWorkflow(workflow: LoadedWorkflow): WorkflowSummary {
|
|
1643
|
+
return {
|
|
1644
|
+
name: workflow.name,
|
|
1645
|
+
providers: Object.entries(workflow.providers).map(([name, provider]) => `${name}:${provider.providerId}`),
|
|
1646
|
+
nodes: collectNodePaths(workflow.root),
|
|
1647
|
+
operations: workflow.operations.map((operation) => operation.id),
|
|
1648
|
+
createsWorkspace: Boolean(workflow.create || workflow.workspace),
|
|
1649
|
+
workspace: workflow.workspace,
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function collectNodePaths(root: WorkflowNodeDefinition<any, any, any>): string[] {
|
|
1654
|
+
const paths: string[] = [];
|
|
1655
|
+
walk(root, [], true);
|
|
1656
|
+
return paths;
|
|
1657
|
+
|
|
1658
|
+
function walk(node: WorkflowNodeDefinition<any, any, any>, prefix: string[], rootNode: boolean, suppress?: string): void {
|
|
1659
|
+
if (node.nodeKind === "task") {
|
|
1660
|
+
paths.push([...prefix, node.name].join("."));
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
if (node.nodeKind === "parallel") {
|
|
1664
|
+
for (const [branchName, branch] of Object.entries(parallelBranches(node))) {
|
|
1665
|
+
walk(branch, [...prefix, branchName], false, branchName);
|
|
1666
|
+
}
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
const sequencePrefix = rootNode || suppress === node.name ? prefix : [...prefix, node.name];
|
|
1670
|
+
for (const child of sequenceChildren(node)) walk(child, sequencePrefix, false);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function providerFingerprintFor(workflow: LoadedWorkflow): string {
|
|
1675
|
+
return hash({
|
|
1676
|
+
providers: Object.fromEntries(
|
|
1677
|
+
Object.entries(workflow.providers).map(([name, provider]) => [
|
|
1678
|
+
name,
|
|
1679
|
+
{
|
|
1680
|
+
providerId: provider.providerId,
|
|
1681
|
+
config: provider.config,
|
|
1682
|
+
},
|
|
1683
|
+
]),
|
|
1684
|
+
),
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function sequenceChildren(node: WorkflowNodeDefinition<any, any, any>): readonly WorkflowNodeDefinition<any, any, any>[] {
|
|
1689
|
+
return (node as { children?: readonly WorkflowNodeDefinition<any, any, any>[] }).children ?? [];
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function parallelBranches(node: WorkflowNodeDefinition<any, any, any>): Record<string, WorkflowNodeDefinition<any, any, any>> {
|
|
1693
|
+
return (node as { branches?: Record<string, WorkflowNodeDefinition<any, any, any>> }).branches ?? {};
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function normalizeTaskOutput(
|
|
1697
|
+
nodePath: string,
|
|
1698
|
+
result: unknown,
|
|
1699
|
+
schema: OutputSchema | undefined,
|
|
1700
|
+
source: "fresh" | "cached",
|
|
1701
|
+
): Record<string, JsonValue> | undefined {
|
|
1702
|
+
const value = schema ? parseWithSchema(schema, result, source) : result;
|
|
1703
|
+
if (value === undefined) return source === "cached" && schema ? undefined : {};
|
|
1704
|
+
if (!isPlainObject(value)) {
|
|
1705
|
+
if (source === "cached") return undefined;
|
|
1706
|
+
throw new Error(`Task ${nodePath} must return an object with JSON-serializable context values`);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1710
|
+
assertJsonValue(item, `Task ${nodePath} return value ${key}`);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
return value as Record<string, JsonValue>;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function parseWithSchema(
|
|
1717
|
+
schema: OutputSchema,
|
|
1718
|
+
value: unknown,
|
|
1719
|
+
source: "fresh" | "cached",
|
|
1720
|
+
): unknown {
|
|
1721
|
+
if ("safeParse" in schema && typeof schema.safeParse === "function") {
|
|
1722
|
+
const result = schema.safeParse(value);
|
|
1723
|
+
if (result.success) return result.data;
|
|
1724
|
+
if (source === "cached") return undefined;
|
|
1725
|
+
throw new Error(`Task output failed schema validation`);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
if ("parse" in schema && typeof schema.parse === "function") {
|
|
1729
|
+
try {
|
|
1730
|
+
return schema.parse(value);
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
if (source === "cached") return undefined;
|
|
1733
|
+
throw error;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
return value;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function collectArtifacts(value: unknown): JsonValue[] {
|
|
1741
|
+
const artifacts: JsonValue[] = [];
|
|
1742
|
+
visit(value);
|
|
1743
|
+
return artifacts;
|
|
1744
|
+
|
|
1745
|
+
function visit(item: unknown): void {
|
|
1746
|
+
if (Array.isArray(item)) {
|
|
1747
|
+
for (const child of item) visit(child);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
if (!isPlainObject(item)) return;
|
|
1751
|
+
if (typeof item.provider === "string" && typeof item.kind === "string") {
|
|
1752
|
+
artifacts.push(item as JsonValue);
|
|
1753
|
+
}
|
|
1754
|
+
for (const child of Object.values(item)) visit(child);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function providerIdOf(value: unknown): string | undefined {
|
|
1759
|
+
return isPlainObject(value) && typeof value.provider === "string" ? value.provider : undefined;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function kindOf(value: unknown): string | undefined {
|
|
1763
|
+
return isPlainObject(value) && typeof value.kind === "string" ? value.kind : undefined;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function snapshotIdOf(value: unknown): string | undefined {
|
|
1767
|
+
return isPlainObject(value) && typeof value.snapshotId === "string" ? value.snapshotId : undefined;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function resolveWorkspaceCwd(workflow: LoadedWorkflow, context: Record<string, JsonValue>): string | undefined {
|
|
1771
|
+
const cwd = workflow.workspace?.cwd;
|
|
1772
|
+
return typeof cwd === "function" ? cwd(context) : cwd;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function normalizeProviderWorkspaceContext(value: unknown): ProviderWorkspaceContext {
|
|
1776
|
+
if (value === undefined) return {};
|
|
1777
|
+
if (!isPlainObject(value)) {
|
|
1778
|
+
throw new Error(`Provider workspace context must be an object`);
|
|
1779
|
+
}
|
|
1780
|
+
return value as ProviderWorkspaceContext;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function assertJsonValue(value: unknown, label: string): asserts value is JsonValue {
|
|
1784
|
+
if (
|
|
1785
|
+
value === null ||
|
|
1786
|
+
typeof value === "string" ||
|
|
1787
|
+
typeof value === "number" ||
|
|
1788
|
+
typeof value === "boolean"
|
|
1789
|
+
) {
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
if (Array.isArray(value)) {
|
|
1794
|
+
value.forEach((item, index) => assertJsonValue(item, `${label}[${index}]`));
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
if (isPlainObject(value)) {
|
|
1799
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1800
|
+
assertJsonValue(item, `${label}.${key}`);
|
|
1801
|
+
}
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
throw new Error(`${label} must be JSON-serializable`);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
function isJsonValue(value: unknown): value is JsonValue {
|
|
1809
|
+
try {
|
|
1810
|
+
assertJsonValue(value, "value");
|
|
1811
|
+
return true;
|
|
1812
|
+
} catch {
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const RESERVED_HOST_OPERATION_IDS = new Set([
|
|
1818
|
+
"completion",
|
|
1819
|
+
"doctor",
|
|
1820
|
+
"help",
|
|
1821
|
+
"init",
|
|
1822
|
+
"projects",
|
|
1823
|
+
"run",
|
|
1824
|
+
"version",
|
|
1825
|
+
]);
|
|
1826
|
+
|
|
1827
|
+
const CORE_OPERATION_INPUT_FIELDS: Record<string, readonly string[]> = {
|
|
1828
|
+
plan: ["workflow"],
|
|
1829
|
+
apply: ["workflow", "dryRun"],
|
|
1830
|
+
create: ["workflow", "name"],
|
|
1831
|
+
ssh: ["workflow", "workspaceOrVmId", "user", "print"],
|
|
1832
|
+
snapshot: ["workflow", "workspace", "label"],
|
|
1833
|
+
delete: ["workflow", "workspace"],
|
|
1834
|
+
};
|
|
1835
|
+
|
|
1836
|
+
function assertAllowedConfigOperationId(operationId: string): void {
|
|
1837
|
+
if (!RESERVED_HOST_OPERATION_IDS.has(operationId)) return;
|
|
1838
|
+
throw new Error(
|
|
1839
|
+
`Config operation "${operationId}" conflicts with a reserved Rigkit host command. ` +
|
|
1840
|
+
`Choose a different operation id.`,
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function stringField(options: {
|
|
1845
|
+
name: string;
|
|
1846
|
+
description?: string;
|
|
1847
|
+
position?: number;
|
|
1848
|
+
required?: boolean;
|
|
1849
|
+
defaultValue?: string;
|
|
1850
|
+
}): WorkflowInputFieldDefinition<string> {
|
|
1851
|
+
return {
|
|
1852
|
+
kind: "string",
|
|
1853
|
+
name: options.name,
|
|
1854
|
+
description: options.description,
|
|
1855
|
+
position: options.position,
|
|
1856
|
+
required: options.required,
|
|
1857
|
+
defaultValue: options.defaultValue,
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function booleanField(options: {
|
|
1862
|
+
name: string;
|
|
1863
|
+
description?: string;
|
|
1864
|
+
position?: number;
|
|
1865
|
+
required?: boolean;
|
|
1866
|
+
defaultValue?: boolean;
|
|
1867
|
+
}): WorkflowInputFieldDefinition<boolean> {
|
|
1868
|
+
return {
|
|
1869
|
+
kind: "boolean",
|
|
1870
|
+
name: options.name,
|
|
1871
|
+
description: options.description,
|
|
1872
|
+
position: options.position,
|
|
1873
|
+
required: options.required,
|
|
1874
|
+
defaultValue: options.defaultValue,
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function parseCoreOperationInput(operation: string, value: unknown): Record<string, unknown> {
|
|
1879
|
+
const raw = value === undefined ? {} : value;
|
|
1880
|
+
if (!isPlainObject(raw)) {
|
|
1881
|
+
throw new EngineOperationValidationError({
|
|
1882
|
+
operation,
|
|
1883
|
+
message: `Operation ${operation} input must be an object`,
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const allowed = new Set(CORE_OPERATION_INPUT_FIELDS[operation] ?? []);
|
|
1888
|
+
const excess = Object.keys(raw).find((key) => !allowed.has(key));
|
|
1889
|
+
if (excess) {
|
|
1890
|
+
throw new EngineOperationValidationError({
|
|
1891
|
+
operation,
|
|
1892
|
+
message: `Operation ${operation} does not accept input ${excess}`,
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
return raw;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function requiredStringInput(operation: string, input: Record<string, unknown>, name: string): string {
|
|
1900
|
+
const value = input[name];
|
|
1901
|
+
if (typeof value !== "string") {
|
|
1902
|
+
throw new EngineOperationValidationError({
|
|
1903
|
+
operation,
|
|
1904
|
+
message: `Operation ${operation} requires ${name}`,
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
const trimmed = value.trim();
|
|
1908
|
+
if (!trimmed) {
|
|
1909
|
+
throw new EngineOperationValidationError({
|
|
1910
|
+
operation,
|
|
1911
|
+
message: `Operation ${operation} requires ${name}`,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
return trimmed;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function optionalStringInput(operation: string, input: Record<string, unknown>, name: string): string | undefined {
|
|
1918
|
+
const value = input[name];
|
|
1919
|
+
if (value === undefined) return undefined;
|
|
1920
|
+
if (typeof value !== "string") {
|
|
1921
|
+
throw new EngineOperationValidationError({
|
|
1922
|
+
operation,
|
|
1923
|
+
message: `Operation ${operation} input ${name} must be a string`,
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
const trimmed = value.trim();
|
|
1927
|
+
if (!trimmed) {
|
|
1928
|
+
throw new EngineOperationValidationError({
|
|
1929
|
+
operation,
|
|
1930
|
+
message: `Operation ${operation} input ${name} must be non-empty`,
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
return trimmed;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function optionalBooleanInput(
|
|
1937
|
+
operation: string,
|
|
1938
|
+
input: Record<string, unknown>,
|
|
1939
|
+
name: string,
|
|
1940
|
+
defaultValue: boolean,
|
|
1941
|
+
): boolean {
|
|
1942
|
+
const value = input[name];
|
|
1943
|
+
if (value === undefined) return defaultValue;
|
|
1944
|
+
if (typeof value !== "boolean") {
|
|
1945
|
+
throw new EngineOperationValidationError({
|
|
1946
|
+
operation,
|
|
1947
|
+
message: `Operation ${operation} input ${name} must be a boolean`,
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
return value;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
1954
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
async function openLocalTarget(target: string): Promise<void> {
|
|
1958
|
+
const command =
|
|
1959
|
+
process.platform === "darwin"
|
|
1960
|
+
? ["open", target]
|
|
1961
|
+
: process.platform === "win32"
|
|
1962
|
+
? ["cmd", "/c", "start", "", target]
|
|
1963
|
+
: ["xdg-open", target];
|
|
1964
|
+
|
|
1965
|
+
const proc = Bun.spawn(command, {
|
|
1966
|
+
stdin: "ignore",
|
|
1967
|
+
stdout: "ignore",
|
|
1968
|
+
stderr: "ignore",
|
|
1969
|
+
});
|
|
1970
|
+
const code = await proc.exited;
|
|
1971
|
+
if (code !== 0) {
|
|
1972
|
+
throw new Error(`Failed to open ${target}`);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
async function requestUnsupportedHostCapability<Result = unknown>(capability: string): Promise<Result> {
|
|
1977
|
+
throw new Error(
|
|
1978
|
+
`Host capability ${capability} is unavailable outside a runtime host. ` +
|
|
1979
|
+
`Run this operation through Rigkit CLI or another host that supports typed host capabilities.`,
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
async function requestUnsupportedHostCapabilitySession<Result = unknown>(
|
|
1984
|
+
capability: string,
|
|
1985
|
+
): Promise<{ result: Result; closed: Promise<void> }> {
|
|
1986
|
+
return {
|
|
1987
|
+
result: await requestUnsupportedHostCapability<Result>(capability),
|
|
1988
|
+
closed: Promise.resolve(),
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
async function runLocalCommand(input: {
|
|
1993
|
+
argv: string[];
|
|
1994
|
+
cwd?: string;
|
|
1995
|
+
env?: Record<string, string | undefined>;
|
|
1996
|
+
stdin?: string | null;
|
|
1997
|
+
mode?: "capture" | "interactive";
|
|
1998
|
+
}): Promise<{ exitCode: number; stdout: string | null; stderr: string | null }> {
|
|
1999
|
+
if (input.argv.length === 0) {
|
|
2000
|
+
throw new Error(`Local command argv must not be empty`);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
if (input.mode === "interactive") {
|
|
2004
|
+
const proc = Bun.spawn(input.argv, {
|
|
2005
|
+
cwd: input.cwd,
|
|
2006
|
+
env: input.env ? { ...process.env, ...input.env } : process.env,
|
|
2007
|
+
stdin: input.stdin === undefined || input.stdin === null ? "inherit" : "pipe",
|
|
2008
|
+
stdout: "inherit",
|
|
2009
|
+
stderr: "inherit",
|
|
2010
|
+
});
|
|
2011
|
+
if (input.stdin !== undefined && input.stdin !== null) {
|
|
2012
|
+
const stdin = proc.stdin;
|
|
2013
|
+
if (!stdin) throw new Error(`Local command stdin is unavailable`);
|
|
2014
|
+
stdin.write(input.stdin);
|
|
2015
|
+
stdin.end();
|
|
2016
|
+
}
|
|
2017
|
+
return { exitCode: await proc.exited, stdout: null, stderr: null };
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
const proc = Bun.spawn(input.argv, {
|
|
2021
|
+
cwd: input.cwd,
|
|
2022
|
+
env: input.env ? { ...process.env, ...input.env } : process.env,
|
|
2023
|
+
stdin: input.stdin === undefined || input.stdin === null ? "ignore" : "pipe",
|
|
2024
|
+
stdout: "pipe",
|
|
2025
|
+
stderr: "pipe",
|
|
2026
|
+
});
|
|
2027
|
+
if (input.stdin !== undefined && input.stdin !== null) {
|
|
2028
|
+
const stdin = proc.stdin;
|
|
2029
|
+
if (!stdin) throw new Error(`Local command stdin is unavailable`);
|
|
2030
|
+
stdin.write(input.stdin);
|
|
2031
|
+
stdin.end();
|
|
2032
|
+
}
|
|
2033
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
2034
|
+
proc.exited,
|
|
2035
|
+
new Response(proc.stdout).text(),
|
|
2036
|
+
new Response(proc.stderr).text(),
|
|
2037
|
+
]);
|
|
2038
|
+
|
|
2039
|
+
return { exitCode, stdout, stderr };
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function isBaseProviderPlugin(value: unknown): value is BaseProviderPlugin {
|
|
2043
|
+
return Boolean(
|
|
2044
|
+
value &&
|
|
2045
|
+
typeof value === "object" &&
|
|
2046
|
+
typeof (value as BaseProviderPlugin).providerId === "string" &&
|
|
2047
|
+
typeof (value as BaseProviderPlugin).createProvider === "function",
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function mergeProviderPlugins(plugins: BaseProviderPlugin[]): BaseProviderPlugin[] {
|
|
2052
|
+
return [...new Map(plugins.map((plugin) => [plugin.providerId, plugin])).values()];
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
async function defaultInteractionPresenter(request: InteractionPresentationRequest): Promise<void> {
|
|
2056
|
+
console.error(`\nInteractive task: ${request.title}`);
|
|
2057
|
+
if (request.instructions) console.error(request.instructions);
|
|
2058
|
+
console.error(`Open ${request.url}`);
|
|
2059
|
+
}
|