@akanjs/devkit 2.3.9-rc.0 → 2.3.9-rc.10

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.
@@ -0,0 +1,590 @@
1
+ import ts from "typescript";
2
+ import type { AkanModuleContext } from "../akanContext";
3
+ import type { Sys, Workspace } from "../commandDecorators";
4
+ import { AppExecutor, LibExecutor } from "../executors";
5
+ import { createWorkflowApplyReport, workflowCommandsForPlan } from "./artifacts";
6
+ import { buildAkanModuleContextIndex } from "./moduleIndex";
7
+ import {
8
+ insertionIndexForFieldOrder,
9
+ inspectConstantStructure,
10
+ inspectDictionaryStructure,
11
+ moduleComponentName,
12
+ moduleSourcePaths,
13
+ normalizeFieldType,
14
+ } from "./source";
15
+ import type {
16
+ PrimitiveChangedFile,
17
+ PrimitiveGeneratedFile,
18
+ PrimitiveNextAction,
19
+ PrimitiveWriteReport,
20
+ WorkflowApplyCommand,
21
+ WorkflowDiagnostic,
22
+ WorkflowInputValue,
23
+ WorkflowPlan,
24
+ WorkflowPostApplyCheck,
25
+ WorkflowPrimitiveOperations,
26
+ WorkflowRecommendation,
27
+ WorkflowStep,
28
+ WorkflowStepRegistry,
29
+ WorkflowStepResult,
30
+ } from "./types";
31
+ import { addFieldUiPolicyForType } from "./uiPolicy";
32
+
33
+ export const workflowStepKey = (workflow: string, stepId: string) => `${workflow}:${stepId}`;
34
+
35
+ export const primitiveReportToWorkflowStepResult = (report: PrimitiveWriteReport): WorkflowStepResult => ({
36
+ changedFiles: report.changedFiles,
37
+ generatedFiles: report.generatedFiles,
38
+ commands: report.validationCommands,
39
+ diagnostics: report.diagnostics,
40
+ recommendations: [],
41
+ nextActions: report.nextActions,
42
+ });
43
+
44
+ const workflowStringInput = (value: WorkflowInputValue | undefined) => (typeof value === "string" ? value : null);
45
+ const workflowStringListInput = (value: WorkflowInputValue | undefined) =>
46
+ Array.isArray(value) ? value.join(",") : null;
47
+ const workflowStringArrayInput = (value: WorkflowInputValue | undefined) => (Array.isArray(value) ? value : null);
48
+ const workflowBooleanInput = (value: WorkflowInputValue | undefined) => (typeof value === "boolean" ? value : null);
49
+
50
+ const postApplyDiagnostic = (code: string, message: string, target: string): WorkflowDiagnostic => ({
51
+ severity: "error",
52
+ code,
53
+ message,
54
+ failureScope: "source-change",
55
+ context: { target },
56
+ });
57
+
58
+ const postApplyWarning = (code: string, message: string, target: string): WorkflowDiagnostic => ({
59
+ severity: "warning",
60
+ code,
61
+ message,
62
+ failureScope: "source-change",
63
+ context: { target },
64
+ });
65
+
66
+ const sourceKindForPath = (filePath: string) => {
67
+ if (filePath.endsWith(".tsx")) return ts.ScriptKind.TSX;
68
+ if (filePath.endsWith(".ts")) return ts.ScriptKind.TS;
69
+ return null;
70
+ };
71
+
72
+ const checkPathCasing = async (workspace: Workspace, filePath: string) => {
73
+ const segments = filePath.split("/").filter(Boolean);
74
+ let current = ".";
75
+ for (const segment of segments) {
76
+ const entries = await workspace.readdir(current);
77
+ if (entries.includes(segment)) {
78
+ current = current === "." ? segment : `${current}/${segment}`;
79
+ continue;
80
+ }
81
+ const caseInsensitiveMatch = entries.find((entry) => entry.toLowerCase() === segment.toLowerCase());
82
+ return caseInsensitiveMatch
83
+ ? {
84
+ code: "workflow-path-casing-mismatch",
85
+ message: `Reported path segment "${segment}" does not match actual casing "${caseInsensitiveMatch}" in ${filePath}.`,
86
+ }
87
+ : {
88
+ code: "workflow-path-missing",
89
+ message: `Reported path does not exist: ${filePath}.`,
90
+ };
91
+ }
92
+ return null;
93
+ };
94
+
95
+ const checkTypeScriptSyntax = async (workspace: Workspace, filePath: string) => {
96
+ const scriptKind = sourceKindForPath(filePath);
97
+ if (!scriptKind) return null;
98
+ const content = await workspace.readFile(filePath);
99
+ const source = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKind);
100
+ const diagnostic = ((source as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] })
101
+ .parseDiagnostics ?? [])[0];
102
+ if (!diagnostic) return null;
103
+ const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
104
+ const location = position ? `:${position.line + 1}:${position.character + 1}` : "";
105
+ return {
106
+ code: "workflow-post-apply-syntax-error",
107
+ message: `Generated source has a TypeScript syntax error at ${filePath}${location}: ${ts.flattenDiagnosticMessageText(
108
+ diagnostic.messageText,
109
+ " ",
110
+ )}`,
111
+ };
112
+ };
113
+
114
+ const checkChangedFile = async (workspace: Workspace, file: PrimitiveChangedFile): Promise<WorkflowStepResult> => {
115
+ if (file.action === "remove") return { postApplyChecks: [] };
116
+ const diagnostics: WorkflowDiagnostic[] = [];
117
+ const checks: WorkflowPostApplyCheck[] = [];
118
+ const pathIssue = await checkPathCasing(workspace, file.path);
119
+ if (pathIssue) {
120
+ diagnostics.push(postApplyDiagnostic(pathIssue.code, pathIssue.message, file.path));
121
+ checks.push({ ...pathIssue, target: file.path, status: "failed" });
122
+ return { diagnostics, postApplyChecks: checks };
123
+ }
124
+ const syntaxIssue = await checkTypeScriptSyntax(workspace, file.path);
125
+ if (syntaxIssue) {
126
+ diagnostics.push(postApplyDiagnostic(syntaxIssue.code, syntaxIssue.message, file.path));
127
+ checks.push({ ...syntaxIssue, target: file.path, status: "failed" });
128
+ return { diagnostics, postApplyChecks: checks };
129
+ }
130
+ checks.push({
131
+ code: "workflow-post-apply-file-valid",
132
+ target: file.path,
133
+ status: "passed",
134
+ message: "Changed file exists with exact casing and parses as source when applicable.",
135
+ });
136
+ return { postApplyChecks: checks };
137
+ };
138
+
139
+ const checkRecommendationPath = async (
140
+ workspace: Workspace,
141
+ recommendation: WorkflowRecommendation,
142
+ ): Promise<WorkflowDiagnostic | null> => {
143
+ if (!recommendation.target || recommendation.target.includes("*") || recommendation.target.startsWith("<"))
144
+ return null;
145
+ const pathIssue = await checkPathCasing(workspace, recommendation.target);
146
+ if (!pathIssue) return null;
147
+ return {
148
+ severity: "warning",
149
+ code: "workflow-recommendation-path-unverified",
150
+ message: `${pathIssue.message} Recommendation "${recommendation.code}" should not be treated as an exact file target until the path is corrected.`,
151
+ failureScope: "source-change",
152
+ context: { target: recommendation.target },
153
+ };
154
+ };
155
+
156
+ const sourceChangeError = (diagnostics: readonly WorkflowDiagnostic[]) =>
157
+ diagnostics.some(
158
+ (diagnostic) =>
159
+ diagnostic.severity === "error" &&
160
+ (diagnostic.failureScope === "source-change" ||
161
+ !diagnostic.failureScope ||
162
+ diagnostic.failureScope === "unknown"),
163
+ );
164
+
165
+ const fieldCount = (fields: readonly string[], fieldName: string) =>
166
+ fields.filter((field) => field === fieldName).length;
167
+
168
+ const fieldOrderValid = (fields: readonly string[], fieldName: string) => {
169
+ const actualIndex = fields.indexOf(fieldName);
170
+ if (actualIndex < 0 || fields.lastIndexOf(fieldName) !== actualIndex) return false;
171
+ const fieldsWithoutRequested = fields.filter((_, index) => index !== actualIndex);
172
+ return insertionIndexForFieldOrder(fieldsWithoutRequested, fieldName) === actualIndex;
173
+ };
174
+
175
+ const workflowModuleContext = async (workspace: Workspace, plan: WorkflowPlan): Promise<AkanModuleContext | null> => {
176
+ const app = workflowStringInput(plan.inputs.app);
177
+ const moduleName = workflowStringInput(plan.inputs.module);
178
+ if (!app || !moduleName) return null;
179
+ const [apps, libs] = await workspace.getSyss();
180
+ const sysType = apps.includes(app) ? "app" : libs.includes(app) ? "lib" : null;
181
+ if (!sysType) return null;
182
+ const modulePath = `${sysType}s/${app}/lib/${moduleName}`;
183
+ const files = await workspace.readdir(modulePath);
184
+ const abstractPath = `${modulePath}/${moduleName}.abstract.md`;
185
+ return {
186
+ kind: "domain",
187
+ name: moduleName,
188
+ folderName: moduleName,
189
+ sysName: app,
190
+ sysType,
191
+ path: modulePath,
192
+ abstract: {
193
+ path: abstractPath,
194
+ exists: files.includes(`${moduleName}.abstract.md`),
195
+ headings: [],
196
+ },
197
+ files,
198
+ };
199
+ };
200
+
201
+ const structureCheck = (
202
+ code: string,
203
+ target: string,
204
+ status: WorkflowPostApplyCheck["status"],
205
+ message: string,
206
+ ): WorkflowPostApplyCheck => ({
207
+ code,
208
+ target,
209
+ status,
210
+ message,
211
+ });
212
+
213
+ const checkAddFieldStructure = async (workspace: Workspace, plan: WorkflowPlan): Promise<WorkflowStepResult> => {
214
+ if (plan.workflow !== "add-field" && plan.workflow !== "add-enum-field") return {};
215
+ const app = workflowStringInput(plan.inputs.app);
216
+ const moduleName = workflowStringInput(plan.inputs.module);
217
+ const fieldName = workflowStringInput(plan.inputs.field);
218
+ const typeName = workflowStringInput(plan.inputs.type);
219
+ if (!app || !moduleName || !fieldName || !typeName) return {};
220
+
221
+ const moduleContext = await workflowModuleContext(workspace, plan);
222
+ if (!moduleContext) return {};
223
+
224
+ const paths = moduleSourcePaths(moduleName);
225
+ const constantPath = `${moduleContext.path}/${paths.constant.replace(`lib/${moduleName}/`, "")}`;
226
+ const dictionaryPath = `${moduleContext.path}/${paths.dictionary.replace(`lib/${moduleName}/`, "")}`;
227
+ const moduleClassName = moduleComponentName(moduleName);
228
+ const inputClassName = `${moduleClassName}Input`;
229
+ const index = await buildAkanModuleContextIndex(workspace, moduleContext, { field: fieldName });
230
+ const diagnostics: WorkflowDiagnostic[] = [];
231
+ const postApplyChecks: WorkflowPostApplyCheck[] = [];
232
+
233
+ const constantContent = await workspace.readFile(constantPath);
234
+ const constantStructure = inspectConstantStructure(constantContent, inputClassName, moduleClassName);
235
+ const constantNames = constantStructure.fields.map((field) => field.name);
236
+ const requestedConstantFields = constantStructure.fields.filter((field) => field.name === fieldName);
237
+ const normalizedType = typeName.toLowerCase() === "enum" ? typeName : normalizeFieldType(typeName);
238
+ const constantFailures = [
239
+ !constantStructure.parseValid ? "constant file does not parse" : null,
240
+ !constantStructure.inputObjectFound ? `${inputClassName} via builder object was not found` : null,
241
+ requestedConstantFields.length !== 1
242
+ ? `field "${fieldName}" appears ${requestedConstantFields.length} time(s) in ${inputClassName}`
243
+ : null,
244
+ constantStructure.builderName &&
245
+ requestedConstantFields[0]?.expressionBuilder &&
246
+ requestedConstantFields[0].expressionBuilder !== constantStructure.builderName
247
+ ? `field "${fieldName}" uses builder "${requestedConstantFields[0].expressionBuilder}" instead of "${constantStructure.builderName}"`
248
+ : null,
249
+ !constantStructure.builderName ? `${inputClassName} via builder parameter was not found` : null,
250
+ (normalizedType === "Int" || normalizedType === "Float") && !constantStructure.baseImports.includes(normalizedType)
251
+ ? `missing ${normalizedType} import from "akanjs/base"`
252
+ : null,
253
+ workflowBooleanInput(plan.inputs.includeInLight) === true &&
254
+ fieldCount(constantStructure.lightProjectionFields, fieldName) !== 1
255
+ ? `field "${fieldName}" is not present exactly once in Light${moduleClassName}`
256
+ : null,
257
+ ...index.diagnostics
258
+ .filter((diagnostic) => diagnostic.severity === "error" && diagnostic.context?.paths?.includes(constantPath))
259
+ .map((diagnostic) => diagnostic.message),
260
+ ].filter((failure): failure is string => failure !== null);
261
+ const constantValid = constantFailures.length === 0;
262
+ postApplyChecks.push(
263
+ structureCheck(
264
+ constantValid ? "workflow-post-apply-constant-shape-valid" : "workflow-post-apply-structure-invalid",
265
+ constantPath,
266
+ constantValid ? "passed" : "failed",
267
+ constantValid
268
+ ? `${inputClassName} keeps via structure, builder usage, imports, and requested field presence.`
269
+ : constantFailures.join(" "),
270
+ ),
271
+ );
272
+ if (!constantValid) {
273
+ diagnostics.push(
274
+ postApplyDiagnostic("workflow-post-apply-structure-invalid", constantFailures.join(" "), constantPath),
275
+ );
276
+ }
277
+
278
+ const dictionaryContent = await workspace.readFile(dictionaryPath);
279
+ const dictionaryStructure = inspectDictionaryStructure(dictionaryContent, moduleClassName);
280
+ const dictionaryFailures = [
281
+ !dictionaryStructure.parseValid ? "dictionary file does not parse" : null,
282
+ !dictionaryStructure.modelObjectFound ? `.model<${moduleClassName}> object was not found` : null,
283
+ dictionaryStructure.modelObjectFound && !dictionaryStructure.chainOrderValid
284
+ ? `.model(), .slice(), .enum(), .error(), and .translate() chain order is broken: ${dictionaryStructure.chainMethods.join(
285
+ " -> ",
286
+ )}`
287
+ : null,
288
+ fieldCount(dictionaryStructure.fields, fieldName) !== 1
289
+ ? `field "${fieldName}" appears ${fieldCount(dictionaryStructure.fields, fieldName)} time(s) in .model<${moduleClassName}>`
290
+ : null,
291
+ ...index.diagnostics
292
+ .filter((diagnostic) => diagnostic.severity === "error" && diagnostic.context?.paths?.includes(dictionaryPath))
293
+ .map((diagnostic) => diagnostic.message),
294
+ ].filter((failure): failure is string => failure !== null);
295
+ const dictionaryValid = dictionaryFailures.length === 0;
296
+ postApplyChecks.push(
297
+ structureCheck(
298
+ dictionaryValid ? "workflow-post-apply-dictionary-shape-valid" : "workflow-post-apply-structure-invalid",
299
+ dictionaryPath,
300
+ dictionaryValid ? "passed" : "failed",
301
+ dictionaryValid
302
+ ? `.model<${moduleClassName}> keeps the requested field inside the model object and preserves dictionary chain order.`
303
+ : dictionaryFailures.join(" "),
304
+ ),
305
+ );
306
+ if (!dictionaryValid) {
307
+ diagnostics.push(
308
+ postApplyDiagnostic("workflow-post-apply-structure-invalid", dictionaryFailures.join(" "), dictionaryPath),
309
+ );
310
+ }
311
+
312
+ const constantOrderValid = fieldOrderValid(constantNames, fieldName);
313
+ const dictionaryOrderValid = fieldOrderValid(dictionaryStructure.fields, fieldName);
314
+ const orderValid = constantOrderValid && dictionaryOrderValid;
315
+ postApplyChecks.push(
316
+ structureCheck(
317
+ "workflow-post-apply-field-order-valid",
318
+ `${constantPath}, ${dictionaryPath}`,
319
+ orderValid ? "passed" : "failed",
320
+ orderValid
321
+ ? `Field "${fieldName}" follows the shared priority ordering policy.`
322
+ : `Field "${fieldName}" is present but does not match the shared priority ordering policy.`,
323
+ ),
324
+ );
325
+ if (!orderValid) {
326
+ diagnostics.push(
327
+ postApplyWarning(
328
+ "workflow-post-apply-field-order-mismatch",
329
+ `Field "${fieldName}" is present but does not match the shared priority ordering policy.`,
330
+ `${constantPath}, ${dictionaryPath}`,
331
+ ),
332
+ );
333
+ }
334
+
335
+ return { diagnostics, postApplyChecks };
336
+ };
337
+
338
+ const resolveWorkflowSys = async (workspace: Workspace, target: string | null): Promise<Sys | null> => {
339
+ if (!target) return null;
340
+ const [apps, libs] = await workspace.getSyss();
341
+ if (apps.includes(target)) return AppExecutor.from(workspace, target);
342
+ if (libs.includes(target)) return LibExecutor.from(workspace, target);
343
+ return null;
344
+ };
345
+
346
+ const targetMissing = (input = "app"): WorkflowDiagnostic => ({
347
+ severity: "error",
348
+ code: "workflow-target-missing",
349
+ input,
350
+ message: "Workflow target app or library was not found.",
351
+ });
352
+
353
+ const inputMissing = (input: string): WorkflowDiagnostic => ({
354
+ severity: "error",
355
+ code: "workflow-input-missing",
356
+ input,
357
+ message: `Workflow input "${input}" is required for apply.`,
358
+ });
359
+
360
+ const unsupportedInput = (input: string, message: string): WorkflowDiagnostic => ({
361
+ severity: "error",
362
+ code: "workflow-input-unsupported",
363
+ input,
364
+ message,
365
+ });
366
+
367
+ const addFieldUiSurfaceInspection = (plan: WorkflowPlan): WorkflowStepResult => {
368
+ const app = workflowStringInput(plan.inputs.app);
369
+ const module = workflowStringInput(plan.inputs.module) ?? "<module>";
370
+ const field = workflowStringInput(plan.inputs.field) ?? "<field>";
371
+ const typeName = workflowStringInput(plan.inputs.type);
372
+ const policy = addFieldUiPolicyForType(typeName ?? "String");
373
+ const surfaces = workflowStringArrayInput(plan.inputs.surfaces);
374
+ const templateRequested = surfaces?.includes("template") ?? false;
375
+ const moduleClassName = moduleComponentName(module);
376
+ const target = `${app ? `apps/${app}` : "*"}/${moduleSourcePaths(module).template}`;
377
+ return {
378
+ recommendations: [
379
+ {
380
+ code: "add-field-ui-surface-review",
381
+ kind: "manual-action",
382
+ target,
383
+ action: templateRequested
384
+ ? `Template was requested for ${field}. If no Template file changed, users will not see the field in the form yet because the file was missing, the generated ${module}Form/Layout.Template pattern was not found, or ${policy.component} needs option binding. Add it inside Layout.Template near existing Field components.`
385
+ : `Template was not selected, so users will not see ${field} in the form from this apply. If list/card display is needed, include ${field} in Light${moduleClassName} projection data and place it in the local Unit/View card layout.`,
386
+ confidence: "medium",
387
+ message: `Review user-visible UI for ${module}.${field}; recommended component is ${policy.component}.`,
388
+ },
389
+ ],
390
+ nextActions: [
391
+ {
392
+ command: `akan workflow explain ${plan.workflow}`,
393
+ reason: "Review UI surface guidance before manually editing ambiguous UI files.",
394
+ },
395
+ ],
396
+ };
397
+ };
398
+
399
+ export const createWorkflowStepRegistry = ({
400
+ workspace,
401
+ createModule,
402
+ createScalar,
403
+ createUi,
404
+ addField,
405
+ addEnumField,
406
+ }: WorkflowPrimitiveOperations): WorkflowStepRegistry => {
407
+ const inspect = async () => undefined;
408
+ const commandOnly = async () => undefined;
409
+
410
+ return {
411
+ inspectSystem: inspect,
412
+ inspectModule: inspect,
413
+ syncTarget: commandOnly,
414
+ lintTarget: commandOnly,
415
+ [workflowStepKey("create-module", "create-module")]: async (_step, plan) => {
416
+ const app = workflowStringInput(plan.inputs.app);
417
+ const module = workflowStringInput(plan.inputs.module);
418
+ const sys = await resolveWorkflowSys(workspace, app);
419
+ if (!sys || !module) return { diagnostics: [!sys ? targetMissing() : inputMissing("module")] };
420
+ return primitiveReportToWorkflowStepResult(await createModule(sys, module));
421
+ },
422
+ [workflowStepKey("create-scalar", "create-scalar")]: async (_step, plan) => {
423
+ const app = workflowStringInput(plan.inputs.app);
424
+ const scalar = workflowStringInput(plan.inputs.scalar);
425
+ const sys = await resolveWorkflowSys(workspace, app);
426
+ if (!sys || !scalar) return { diagnostics: [!sys ? targetMissing() : inputMissing("scalar")] };
427
+ return primitiveReportToWorkflowStepResult(await createScalar(sys, scalar));
428
+ },
429
+ [workflowStepKey("create-ui", "create-ui")]: async (_step, plan) => {
430
+ const surface = workflowStringInput(plan.inputs.surface);
431
+ if (surface !== "view" && surface !== "unit" && surface !== "template") {
432
+ return {
433
+ diagnostics: [
434
+ unsupportedInput("surface", "Workflow apply currently supports create-ui surfaces: view, unit, template."),
435
+ ],
436
+ };
437
+ }
438
+ return primitiveReportToWorkflowStepResult(
439
+ await createUi({
440
+ app: workflowStringInput(plan.inputs.app),
441
+ module: workflowStringInput(plan.inputs.module),
442
+ surface,
443
+ }),
444
+ );
445
+ },
446
+ [workflowStepKey("add-field", "update-constant")]: async (_step, plan) => {
447
+ if (workflowStringInput(plan.inputs.type)?.toLowerCase() === "enum") {
448
+ return primitiveReportToWorkflowStepResult(
449
+ await addEnumField({
450
+ app: workflowStringInput(plan.inputs.app),
451
+ module: workflowStringInput(plan.inputs.module),
452
+ field: workflowStringInput(plan.inputs.field),
453
+ values: workflowStringListInput(plan.inputs.values),
454
+ defaultValue: workflowStringInput(plan.inputs.default),
455
+ surfaces: workflowStringArrayInput(plan.inputs.surfaces),
456
+ includeInLight: workflowBooleanInput(plan.inputs.includeInLight),
457
+ }),
458
+ );
459
+ }
460
+ return primitiveReportToWorkflowStepResult(
461
+ await addField({
462
+ app: workflowStringInput(plan.inputs.app),
463
+ module: workflowStringInput(plan.inputs.module),
464
+ field: workflowStringInput(plan.inputs.field),
465
+ type: workflowStringInput(plan.inputs.type),
466
+ defaultValue: workflowStringInput(plan.inputs.default),
467
+ surfaces: workflowStringArrayInput(plan.inputs.surfaces),
468
+ includeInLight: workflowBooleanInput(plan.inputs.includeInLight),
469
+ }),
470
+ );
471
+ },
472
+ [workflowStepKey("add-field", "update-dictionary")]: inspect,
473
+ [workflowStepKey("add-field", "update-ui-surfaces")]: async (_step, plan) => addFieldUiSurfaceInspection(plan),
474
+ [workflowStepKey("add-enum-field", "update-constant")]: async (_step, plan) =>
475
+ primitiveReportToWorkflowStepResult(
476
+ await addEnumField({
477
+ app: workflowStringInput(plan.inputs.app),
478
+ module: workflowStringInput(plan.inputs.module),
479
+ field: workflowStringInput(plan.inputs.field),
480
+ values: workflowStringListInput(plan.inputs.values),
481
+ defaultValue: workflowStringInput(plan.inputs.default),
482
+ }),
483
+ ),
484
+ [workflowStepKey("add-enum-field", "update-dictionary")]: inspect,
485
+ [workflowStepKey("add-enum-field", "update-option")]: inspect,
486
+ };
487
+ };
488
+
489
+ export const createWorkflowStepCommandResult = (
490
+ step: WorkflowStep,
491
+ command: string,
492
+ reason: string,
493
+ ): WorkflowStepResult => ({
494
+ commands: [{ command, reason, stepId: step.id }],
495
+ nextActions: [{ command, reason }],
496
+ });
497
+
498
+ export class WorkflowExecutor {
499
+ constructor(
500
+ private readonly registry: WorkflowStepRegistry,
501
+ private readonly workspace?: Workspace,
502
+ ) {}
503
+
504
+ async apply(plan: WorkflowPlan) {
505
+ const changedFiles: PrimitiveChangedFile[] = [];
506
+ const generatedFiles: PrimitiveGeneratedFile[] = [];
507
+ const recommendedValidationCommands: WorkflowApplyCommand[] = [];
508
+ const diagnostics: WorkflowDiagnostic[] = [...plan.diagnostics];
509
+ const postApplyChecks: WorkflowPostApplyCheck[] = [];
510
+ const recommendations = [...plan.recommendations];
511
+ const nextActions: PrimitiveNextAction[] = [];
512
+
513
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
514
+ return createWorkflowApplyReport({
515
+ workflow: plan.workflow,
516
+ mode: "apply",
517
+ changedFiles,
518
+ generatedFiles,
519
+ recommendedValidationCommands,
520
+ diagnostics,
521
+ postApplyChecks,
522
+ recommendations,
523
+ nextActions,
524
+ plan,
525
+ });
526
+ }
527
+
528
+ recommendedValidationCommands.push(...workflowCommandsForPlan(plan));
529
+ nextActions.push(...workflowCommandsForPlan(plan));
530
+
531
+ for (const step of plan.steps) {
532
+ const runner =
533
+ this.registry[workflowStepKey(plan.workflow, step.id)] ?? this.registry[step.tool] ?? this.registry[step.id];
534
+ if (!runner) {
535
+ diagnostics.push({
536
+ severity: "error",
537
+ code: "workflow-step-unsupported",
538
+ message: `Workflow ${plan.workflow} step "${step.id}" is not supported by workflow apply yet.`,
539
+ });
540
+ nextActions.push({
541
+ command: `akan workflow explain ${plan.workflow}`,
542
+ reason: "Review the unsupported workflow step before retrying apply.",
543
+ });
544
+ break;
545
+ }
546
+
547
+ const result = await runner(step, plan);
548
+ if (!result) continue;
549
+ changedFiles.push(...(result.changedFiles ?? []));
550
+ generatedFiles.push(...(result.generatedFiles ?? []));
551
+ recommendedValidationCommands.push(...(result.commands ?? []));
552
+ diagnostics.push(...(result.diagnostics ?? []));
553
+ recommendations.push(...(result.recommendations ?? []));
554
+ nextActions.push(...(result.nextActions ?? []));
555
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) break;
556
+ }
557
+
558
+ if (this.workspace && !diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
559
+ for (const file of changedFiles) {
560
+ const result = await checkChangedFile(this.workspace, file);
561
+ postApplyChecks.push(...(result.postApplyChecks ?? []));
562
+ diagnostics.push(...(result.diagnostics ?? []));
563
+ }
564
+ if (!sourceChangeError(diagnostics)) {
565
+ const result = await checkAddFieldStructure(this.workspace, plan);
566
+ postApplyChecks.push(...(result.postApplyChecks ?? []));
567
+ diagnostics.push(...(result.diagnostics ?? []));
568
+ }
569
+ const recommendationDiagnostics = await Promise.all(
570
+ recommendations.map((recommendation) => checkRecommendationPath(this.workspace as Workspace, recommendation)),
571
+ );
572
+ diagnostics.push(
573
+ ...recommendationDiagnostics.filter((diagnostic): diagnostic is WorkflowDiagnostic => !!diagnostic),
574
+ );
575
+ }
576
+
577
+ return createWorkflowApplyReport({
578
+ workflow: plan.workflow,
579
+ mode: "apply",
580
+ changedFiles,
581
+ generatedFiles,
582
+ recommendedValidationCommands,
583
+ diagnostics,
584
+ postApplyChecks,
585
+ recommendations,
586
+ nextActions,
587
+ plan,
588
+ });
589
+ }
590
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./artifacts";
2
+ export * from "./executor";
3
+ export * from "./moduleIndex";
4
+ export * from "./plan";
5
+ export * from "./primitive";
6
+ export * from "./render";
7
+ export * from "./rolloutGate";
8
+ export * from "./source";
9
+ export * from "./types";
10
+ export * from "./uiPolicy";
11
+ export * from "./utils";