@aicoffe/ai-native-sdlc-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +25 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +21 -0
- package/dist/tools/workflow-tools.d.ts +14 -0
- package/dist/tools/workflow-tools.js +171 -0
- package/dist/workflow/definition.d.ts +2 -0
- package/dist/workflow/definition.js +252 -0
- package/dist/workflow/event-bus.d.ts +16 -0
- package/dist/workflow/event-bus.js +46 -0
- package/dist/workflow/events.d.ts +12 -0
- package/dist/workflow/events.js +50 -0
- package/dist/workflow/handlers/close-intent.d.ts +2 -0
- package/dist/workflow/handlers/close-intent.js +21 -0
- package/dist/workflow/handlers/contain.d.ts +13 -0
- package/dist/workflow/handlers/contain.js +42 -0
- package/dist/workflow/handlers/create-feature-branch.d.ts +2 -0
- package/dist/workflow/handlers/create-feature-branch.js +25 -0
- package/dist/workflow/handlers/create-pr.d.ts +2 -0
- package/dist/workflow/handlers/create-pr.js +45 -0
- package/dist/workflow/handlers/create-spec-dir.d.ts +2 -0
- package/dist/workflow/handlers/create-spec-dir.js +50 -0
- package/dist/workflow/handlers/exec.d.ts +5 -0
- package/dist/workflow/handlers/exec.js +13 -0
- package/dist/workflow/handlers/fs-utils.d.ts +1 -0
- package/dist/workflow/handlers/fs-utils.js +11 -0
- package/dist/workflow/handlers/gh.d.ts +42 -0
- package/dist/workflow/handlers/gh.js +57 -0
- package/dist/workflow/handlers/git.d.ts +12 -0
- package/dist/workflow/handlers/git.js +59 -0
- package/dist/workflow/handlers/index.d.ts +12 -0
- package/dist/workflow/handlers/index.js +20 -0
- package/dist/workflow/handlers/merge-pr.d.ts +2 -0
- package/dist/workflow/handlers/merge-pr.js +13 -0
- package/dist/workflow/handlers/noop.d.ts +2 -0
- package/dist/workflow/handlers/noop.js +1 -0
- package/dist/workflow/handlers/notify-blocked.d.ts +2 -0
- package/dist/workflow/handlers/notify-blocked.js +12 -0
- package/dist/workflow/handlers/update-current-index.d.ts +2 -0
- package/dist/workflow/handlers/update-current-index.js +73 -0
- package/dist/workflow/ids.d.ts +27 -0
- package/dist/workflow/ids.js +63 -0
- package/dist/workflow/mutex.d.ts +12 -0
- package/dist/workflow/mutex.js +24 -0
- package/dist/workflow/paths.d.ts +6 -0
- package/dist/workflow/paths.js +15 -0
- package/dist/workflow/retry.d.ts +14 -0
- package/dist/workflow/retry.js +52 -0
- package/dist/workflow/state-machine.d.ts +46 -0
- package/dist/workflow/state-machine.js +313 -0
- package/dist/workflow/store.d.ts +30 -0
- package/dist/workflow/store.js +95 -0
- package/dist/workflow/template.d.ts +4 -0
- package/dist/workflow/template.js +18 -0
- package/dist/workflow/types.d.ts +102 -0
- package/dist/workflow/types.js +3 -0
- package/package.json +45 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const SLUG_MAX_LENGTH = 64;
|
|
2
|
+
/**
|
|
3
|
+
* Lowercase, NFKD-fold, keep only [a-z0-9-], collapse consecutive hyphens,
|
|
4
|
+
* trim hyphens, cap at 64 chars. Returns '' when nothing survives (e.g.
|
|
5
|
+
* CJK-only titles) — callers apply the 'wf-NNN' fallback via generateIds.
|
|
6
|
+
*/
|
|
7
|
+
export declare function slugify(title: string): string;
|
|
8
|
+
/** UTC date as YYYYMMDD. */
|
|
9
|
+
export declare function dateCompact(now: Date): string;
|
|
10
|
+
/** UTC date as YYYY-MM-DD. */
|
|
11
|
+
export declare function dateIso(now: Date): string;
|
|
12
|
+
export interface WorkflowIds {
|
|
13
|
+
workflow_id: string;
|
|
14
|
+
intent_id: string;
|
|
15
|
+
slug: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Generate ids for a new workflow. `dailyCount` is the 1-based per-day counter
|
|
19
|
+
* (persisted in the store index by the caller). The same counter feeds the
|
|
20
|
+
* 'wf-NNN' slug fallback for titles with no ASCII content.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateIds(title: string, now: Date, dailyCount: number): WorkflowIds;
|
|
23
|
+
/**
|
|
24
|
+
* Next per-day counter given all known workflow ids: max existing NNN for the
|
|
25
|
+
* day (zero-padded 3 digits), +1; starts at 1.
|
|
26
|
+
*/
|
|
27
|
+
export declare function nextDailyCount(existingWorkflowIds: string[], compactDate: string): number;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// mcp-server/src/workflow/ids.ts
|
|
2
|
+
// Workflow/intent id generation and slug helpers.
|
|
3
|
+
//
|
|
4
|
+
// All dates are UTC (toISOString). The daily counter is injected by the caller
|
|
5
|
+
// (derived from the persisted store index) so this module stays pure.
|
|
6
|
+
export const SLUG_MAX_LENGTH = 64;
|
|
7
|
+
/**
|
|
8
|
+
* Lowercase, NFKD-fold, keep only [a-z0-9-], collapse consecutive hyphens,
|
|
9
|
+
* trim hyphens, cap at 64 chars. Returns '' when nothing survives (e.g.
|
|
10
|
+
* CJK-only titles) — callers apply the 'wf-NNN' fallback via generateIds.
|
|
11
|
+
*/
|
|
12
|
+
export function slugify(title) {
|
|
13
|
+
const folded = title
|
|
14
|
+
.normalize('NFKD')
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/\s+/g, '-') // whitespace becomes a hyphen separator
|
|
17
|
+
.replace(/[^a-z0-9-]/g, '') // strip everything else (incl. combining marks)
|
|
18
|
+
.replace(/-{2,}/g, '-') // collapse consecutive hyphens
|
|
19
|
+
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
|
20
|
+
return folded
|
|
21
|
+
.slice(0, SLUG_MAX_LENGTH)
|
|
22
|
+
.replace(/-+$/g, ''); // re-trim after the cap may cut mid-hyphen
|
|
23
|
+
}
|
|
24
|
+
/** UTC date as YYYYMMDD. */
|
|
25
|
+
export function dateCompact(now) {
|
|
26
|
+
return now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
27
|
+
}
|
|
28
|
+
/** UTC date as YYYY-MM-DD. */
|
|
29
|
+
export function dateIso(now) {
|
|
30
|
+
return now.toISOString().slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
function pad3(n) {
|
|
33
|
+
return String(n).padStart(3, '0');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Generate ids for a new workflow. `dailyCount` is the 1-based per-day counter
|
|
37
|
+
* (persisted in the store index by the caller). The same counter feeds the
|
|
38
|
+
* 'wf-NNN' slug fallback for titles with no ASCII content.
|
|
39
|
+
*/
|
|
40
|
+
export function generateIds(title, now, dailyCount) {
|
|
41
|
+
const compact = dateCompact(now);
|
|
42
|
+
const workflow_id = `WF-${compact}-${pad3(dailyCount)}`;
|
|
43
|
+
const intent_id = `INTENT-${compact}-${pad3(dailyCount)}`;
|
|
44
|
+
const slug = slugify(title) || `wf-${pad3(dailyCount)}`;
|
|
45
|
+
return { workflow_id, intent_id, slug };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Next per-day counter given all known workflow ids: max existing NNN for the
|
|
49
|
+
* day (zero-padded 3 digits), +1; starts at 1.
|
|
50
|
+
*/
|
|
51
|
+
export function nextDailyCount(existingWorkflowIds, compactDate) {
|
|
52
|
+
const prefix = `WF-${compactDate}-`;
|
|
53
|
+
let max = 0;
|
|
54
|
+
for (const id of existingWorkflowIds) {
|
|
55
|
+
if (!id.startsWith(prefix))
|
|
56
|
+
continue;
|
|
57
|
+
const tail = id.slice(prefix.length);
|
|
58
|
+
if (!/^\d{3}$/.test(tail))
|
|
59
|
+
continue;
|
|
60
|
+
max = Math.max(max, Number.parseInt(tail, 10));
|
|
61
|
+
}
|
|
62
|
+
return max + 1;
|
|
63
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed promise-queue mutex. The mutating workflow tools serialize on a
|
|
3
|
+
* single REPO-LEVEL key ('$repo'), NOT per-workflow keys: action handlers
|
|
4
|
+
* operate on the ONE shared git working tree, so concurrent workflows would
|
|
5
|
+
* otherwise race each other (e.g. workflow B's branch checkout switching
|
|
6
|
+
* branches under workflow A's in-flight commit). The implementation stays
|
|
7
|
+
* generic per-key; only the keys used by callers changed.
|
|
8
|
+
*/
|
|
9
|
+
export declare class KeyedMutex {
|
|
10
|
+
private readonly tails;
|
|
11
|
+
run<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// mcp-server/src/workflow/mutex.ts
|
|
2
|
+
/**
|
|
3
|
+
* Keyed promise-queue mutex. The mutating workflow tools serialize on a
|
|
4
|
+
* single REPO-LEVEL key ('$repo'), NOT per-workflow keys: action handlers
|
|
5
|
+
* operate on the ONE shared git working tree, so concurrent workflows would
|
|
6
|
+
* otherwise race each other (e.g. workflow B's branch checkout switching
|
|
7
|
+
* branches under workflow A's in-flight commit). The implementation stays
|
|
8
|
+
* generic per-key; only the keys used by callers changed.
|
|
9
|
+
*/
|
|
10
|
+
export class KeyedMutex {
|
|
11
|
+
tails = new Map();
|
|
12
|
+
async run(key, fn) {
|
|
13
|
+
const prev = this.tails.get(key) ?? Promise.resolve();
|
|
14
|
+
const next = prev.then(fn, fn);
|
|
15
|
+
const tail = next.then(() => undefined, () => undefined);
|
|
16
|
+
this.tails.set(key, tail);
|
|
17
|
+
void tail.then(() => {
|
|
18
|
+
if (this.tails.get(key) === tail) {
|
|
19
|
+
this.tails.delete(key);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defense-in-depth containment for store/event paths built from a workflow_id:
|
|
3
|
+
* resolve under baseDir and assert the result stays there. Traversal ids
|
|
4
|
+
* (e.g. "../../etc/x") are rejected before any filesystem touch.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveUnderBase(baseDir: string, relPath: string): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// mcp-server/src/workflow/paths.ts
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Defense-in-depth containment for store/event paths built from a workflow_id:
|
|
5
|
+
* resolve under baseDir and assert the result stays there. Traversal ids
|
|
6
|
+
* (e.g. "../../etc/x") are rejected before any filesystem touch.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveUnderBase(baseDir, relPath) {
|
|
9
|
+
const base = path.resolve(baseDir);
|
|
10
|
+
const resolved = path.resolve(base, relPath);
|
|
11
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
|
12
|
+
throw new Error(`workflow_id escapes store directory: ${relPath}`);
|
|
13
|
+
}
|
|
14
|
+
return resolved;
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type SleepFn = (ms: number) => Promise<void>;
|
|
2
|
+
export declare const defaultSleep: SleepFn;
|
|
3
|
+
export declare function isRetryable(error: Error): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Runs a task with up to `maxRetries` attempts (default 3), exponential
|
|
6
|
+
* backoff Math.pow(2, i) * 1000 ms between attempts, retrying only when
|
|
7
|
+
* isRetryable(error) is true. Non-retryable errors and the final attempt's
|
|
8
|
+
* error propagate to the caller.
|
|
9
|
+
*/
|
|
10
|
+
export declare class RetryableAction {
|
|
11
|
+
private readonly sleep;
|
|
12
|
+
constructor(sleep?: SleepFn);
|
|
13
|
+
execute<T>(task: () => Promise<T>, maxRetries?: number): Promise<T>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// mcp-server/src/workflow/retry.ts
|
|
2
|
+
import { GhUnavailableError } from './handlers/gh.js';
|
|
3
|
+
export const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
export function isRetryable(error) {
|
|
5
|
+
if (error instanceof GhUnavailableError)
|
|
6
|
+
return false;
|
|
7
|
+
const message = error.message;
|
|
8
|
+
if (message.includes('ETIMEDOUT'))
|
|
9
|
+
return true;
|
|
10
|
+
if (message.includes('ECONNREFUSED'))
|
|
11
|
+
return true;
|
|
12
|
+
if (message.includes('rate limit'))
|
|
13
|
+
return true;
|
|
14
|
+
if (message.includes('not found'))
|
|
15
|
+
return false;
|
|
16
|
+
if (message.includes('permission denied'))
|
|
17
|
+
return false;
|
|
18
|
+
if (message.includes('ENOENT'))
|
|
19
|
+
return false;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Runs a task with up to `maxRetries` attempts (default 3), exponential
|
|
24
|
+
* backoff Math.pow(2, i) * 1000 ms between attempts, retrying only when
|
|
25
|
+
* isRetryable(error) is true. Non-retryable errors and the final attempt's
|
|
26
|
+
* error propagate to the caller.
|
|
27
|
+
*/
|
|
28
|
+
export class RetryableAction {
|
|
29
|
+
sleep;
|
|
30
|
+
constructor(sleep = defaultSleep) {
|
|
31
|
+
this.sleep = sleep;
|
|
32
|
+
}
|
|
33
|
+
async execute(task, maxRetries = 3) {
|
|
34
|
+
let lastError;
|
|
35
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
36
|
+
try {
|
|
37
|
+
return await task();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
lastError = error;
|
|
41
|
+
if (!isRetryable(asError(error)) || i === maxRetries - 1) {
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
await this.sleep(Math.pow(2, i) * 1000);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw lastError;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function asError(error) {
|
|
51
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
52
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Stage, WorkflowDefinition, WorkflowState } from './types.js';
|
|
2
|
+
import { WorkflowEventBus } from './event-bus.js';
|
|
3
|
+
import type { WorkflowStore } from './store.js';
|
|
4
|
+
import type { ActionHandler } from './handlers/index.js';
|
|
5
|
+
import { type SleepFn } from './retry.js';
|
|
6
|
+
export interface StateMachineOptions {
|
|
7
|
+
sleep?: SleepFn;
|
|
8
|
+
maxRetries?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class WorkflowStateMachine {
|
|
11
|
+
private readonly definition;
|
|
12
|
+
private readonly stateStore;
|
|
13
|
+
private readonly eventBus;
|
|
14
|
+
private readonly handlers;
|
|
15
|
+
private readonly retry;
|
|
16
|
+
private readonly maxRetries;
|
|
17
|
+
private readonly offs;
|
|
18
|
+
constructor(definition: WorkflowDefinition, stateStore: WorkflowStore, eventBus: WorkflowEventBus, actionHandlers: Record<string, ActionHandler>, options?: StateMachineOptions);
|
|
19
|
+
/** Detaches the machine from the bus (tests rebuild machines on one bus). */
|
|
20
|
+
dispose(): void;
|
|
21
|
+
private handleStageCompleted;
|
|
22
|
+
private handleGateDecision;
|
|
23
|
+
private handleActionCompleted;
|
|
24
|
+
private handleActionFailed;
|
|
25
|
+
private transition;
|
|
26
|
+
private executeAction;
|
|
27
|
+
private failedEvent;
|
|
28
|
+
/** Pure payload builder for llm stages; no side effects, no events. */
|
|
29
|
+
triggerLLMStage(stage: Stage, state: WorkflowState): Record<string, unknown>;
|
|
30
|
+
/** Pure payload builder for gates; no side effects, no events. */
|
|
31
|
+
presentGate(stage: Stage, state: WorkflowState): Record<string, unknown>;
|
|
32
|
+
/** Doc §6 view consumed by buildToolResponse: llm_generate / gate_wait / processing. */
|
|
33
|
+
getCurrentStageView(state: WorkflowState): Record<string, unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* D10: walk the definition backwards from the current gate to the nearest
|
|
36
|
+
* preceding llm_generation stage; prefer its RECORDED outputs from the
|
|
37
|
+
* stage.completed event, fall back to rendered stage.outputs templates.
|
|
38
|
+
*/
|
|
39
|
+
collectArtifacts(state: WorkflowState): string[];
|
|
40
|
+
getStage(stageId: string): Stage;
|
|
41
|
+
renderPath(template: string, state: WorkflowState): string;
|
|
42
|
+
renderPrompt(template: string | undefined, state: WorkflowState): string;
|
|
43
|
+
private requireWorkflow;
|
|
44
|
+
private requireCurrentStage;
|
|
45
|
+
recoverWorkflows(): Promise<void>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { isRetryable, RetryableAction } from './retry.js';
|
|
2
|
+
import { renderStrict, renderTemplate } from './template.js';
|
|
3
|
+
import { validateContextVars } from './handlers/contain.js';
|
|
4
|
+
export class WorkflowStateMachine {
|
|
5
|
+
definition;
|
|
6
|
+
stateStore;
|
|
7
|
+
eventBus;
|
|
8
|
+
handlers;
|
|
9
|
+
retry;
|
|
10
|
+
maxRetries;
|
|
11
|
+
offs = [];
|
|
12
|
+
constructor(definition, stateStore, eventBus, actionHandlers, options = {}) {
|
|
13
|
+
this.definition = definition;
|
|
14
|
+
this.stateStore = stateStore;
|
|
15
|
+
this.eventBus = eventBus;
|
|
16
|
+
this.handlers = actionHandlers;
|
|
17
|
+
this.retry = new RetryableAction(options.sleep);
|
|
18
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
19
|
+
this.offs.push(this.eventBus.on('stage.completed', (event) => this.handleStageCompleted(event)), this.eventBus.on('gate.decision', (event) => this.handleGateDecision(event)), this.eventBus.on('action.completed', (event) => this.handleActionCompleted(event)), this.eventBus.on('action.failed', (event) => this.handleActionFailed(event)));
|
|
20
|
+
}
|
|
21
|
+
/** Detaches the machine from the bus (tests rebuild machines on one bus). */
|
|
22
|
+
dispose() {
|
|
23
|
+
for (const off of this.offs)
|
|
24
|
+
off();
|
|
25
|
+
this.offs.length = 0;
|
|
26
|
+
}
|
|
27
|
+
// ========== 事件处理器 ==========
|
|
28
|
+
async handleStageCompleted(event) {
|
|
29
|
+
const state = await this.requireWorkflow(event.workflow_id);
|
|
30
|
+
this.requireCurrentStage(state, event.stage_id);
|
|
31
|
+
const currentStage = this.getStage(state.current_stage);
|
|
32
|
+
if (currentStage.type !== 'llm_generation') {
|
|
33
|
+
throw new Error(`Stage ${currentStage.id} is not llm_generation`);
|
|
34
|
+
}
|
|
35
|
+
state.stages[currentStage.id] = {
|
|
36
|
+
status: 'completed',
|
|
37
|
+
output: event.output,
|
|
38
|
+
completed_at: event.timestamp,
|
|
39
|
+
};
|
|
40
|
+
await this.transition(state, currentStage.next);
|
|
41
|
+
}
|
|
42
|
+
async handleGateDecision(event) {
|
|
43
|
+
const state = await this.requireWorkflow(event.workflow_id);
|
|
44
|
+
this.requireCurrentStage(state, event.stage_id);
|
|
45
|
+
const currentStage = this.getStage(state.current_stage);
|
|
46
|
+
if (currentStage.type !== 'gate') {
|
|
47
|
+
throw new Error(`Stage ${currentStage.id} is not a gate`);
|
|
48
|
+
}
|
|
49
|
+
if (!(currentStage.decisions ?? []).includes(event.decision)) {
|
|
50
|
+
throw new Error(`Decision ${event.decision} is not allowed at gate ${currentStage.id}`);
|
|
51
|
+
}
|
|
52
|
+
if (event.decision === 'approved') {
|
|
53
|
+
state.stages[currentStage.id] = {
|
|
54
|
+
status: 'approved',
|
|
55
|
+
approved_by: event.approved_by,
|
|
56
|
+
approved_at: event.timestamp,
|
|
57
|
+
};
|
|
58
|
+
await this.transition(state, currentStage.next);
|
|
59
|
+
}
|
|
60
|
+
else if (event.decision === 'rejected') {
|
|
61
|
+
state.stages[currentStage.id] = {
|
|
62
|
+
status: 'rejected',
|
|
63
|
+
rejected_by: event.approved_by,
|
|
64
|
+
rejected_at: event.timestamp,
|
|
65
|
+
reason: event.changes,
|
|
66
|
+
};
|
|
67
|
+
await this.transition(state, currentStage.on_reject || currentStage.id);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
state.stages[currentStage.id] = {
|
|
71
|
+
status: 'pending',
|
|
72
|
+
modified_at: event.timestamp,
|
|
73
|
+
changes: event.changes,
|
|
74
|
+
};
|
|
75
|
+
await this.transition(state, currentStage.id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async handleActionCompleted(event) {
|
|
79
|
+
const state = await this.requireWorkflow(event.workflow_id);
|
|
80
|
+
this.requireCurrentStage(state, event.stage_id);
|
|
81
|
+
const currentStage = this.getStage(state.current_stage);
|
|
82
|
+
if (currentStage.type !== 'action') {
|
|
83
|
+
throw new Error(`Stage ${currentStage.id} is not an action`);
|
|
84
|
+
}
|
|
85
|
+
state.stages[currentStage.id] = {
|
|
86
|
+
status: 'completed',
|
|
87
|
+
result: event.result,
|
|
88
|
+
completed_at: event.timestamp,
|
|
89
|
+
};
|
|
90
|
+
if (event.result.context_updates) {
|
|
91
|
+
state.context = { ...state.context, ...event.result.context_updates };
|
|
92
|
+
}
|
|
93
|
+
await this.transition(state, currentStage.next);
|
|
94
|
+
}
|
|
95
|
+
async handleActionFailed(event) {
|
|
96
|
+
const state = await this.requireWorkflow(event.workflow_id);
|
|
97
|
+
this.requireCurrentStage(state, event.stage_id);
|
|
98
|
+
const currentStage = this.getStage(state.current_stage);
|
|
99
|
+
state.stages[event.stage_id] = {
|
|
100
|
+
status: 'failed',
|
|
101
|
+
error: event.error,
|
|
102
|
+
retryable: event.retryable,
|
|
103
|
+
failed_at: event.timestamp,
|
|
104
|
+
};
|
|
105
|
+
if (event.retryable) {
|
|
106
|
+
// Stays 'running' on the current action stage; startup recovery re-drives it.
|
|
107
|
+
state.status = 'running';
|
|
108
|
+
await this.stateStore.save(state);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const targetStageId = currentStage.on_error || 'blocked';
|
|
112
|
+
if (targetStageId === state.current_stage) {
|
|
113
|
+
// Already ON the on_error stage and its own handler failed non-retryably:
|
|
114
|
+
// block and stop — never re-transition (no infinite loop).
|
|
115
|
+
state.status = 'blocked';
|
|
116
|
+
await this.stateStore.save(state);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await this.transition(state, targetStageId);
|
|
120
|
+
}
|
|
121
|
+
// ========== 核心流转逻辑 ==========
|
|
122
|
+
async transition(state, nextStageId) {
|
|
123
|
+
const nextStage = this.getStage(nextStageId);
|
|
124
|
+
// D2 terminal self-loop guard: 'completed'/'blocked' are self-looping action
|
|
125
|
+
// stages; transitioning onto ourselves must not re-execute the handler.
|
|
126
|
+
if (nextStageId === state.current_stage && nextStage.type === 'action') {
|
|
127
|
+
state.status = nextStageId === 'completed' ? 'completed' : 'blocked';
|
|
128
|
+
state.last_active = new Date().toISOString();
|
|
129
|
+
await this.stateStore.save(state);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
state.current_stage = nextStageId;
|
|
133
|
+
state.last_active = new Date().toISOString();
|
|
134
|
+
switch (nextStage.type) {
|
|
135
|
+
case 'llm_generation':
|
|
136
|
+
state.status = 'running';
|
|
137
|
+
await this.stateStore.save(state);
|
|
138
|
+
break;
|
|
139
|
+
case 'gate':
|
|
140
|
+
state.status = 'waiting_for_user';
|
|
141
|
+
await this.stateStore.save(state);
|
|
142
|
+
break;
|
|
143
|
+
case 'action':
|
|
144
|
+
// 状态先落盘:save BEFORE executing the side effect.
|
|
145
|
+
state.status = 'running';
|
|
146
|
+
await this.stateStore.save(state);
|
|
147
|
+
await this.executeAction(nextStage, state);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ========== Action 执行 ==========
|
|
152
|
+
async executeAction(stage, state) {
|
|
153
|
+
const handler = this.handlers[stage.handler];
|
|
154
|
+
if (!handler) {
|
|
155
|
+
await this.eventBus.emitWorkflowEvent(this.failedEvent(state.workflow_id, stage.id, `Unknown action handler: ${stage.handler}`, false));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const result = await this.retry.execute(() => handler(stage.handler_params ?? {}, state), this.maxRetries);
|
|
160
|
+
await this.eventBus.emitWorkflowEvent({
|
|
161
|
+
type: 'action.completed',
|
|
162
|
+
workflow_id: state.workflow_id,
|
|
163
|
+
stage_id: stage.id,
|
|
164
|
+
result,
|
|
165
|
+
timestamp: new Date().toISOString(),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
170
|
+
await this.eventBus.emitWorkflowEvent(this.failedEvent(state.workflow_id, stage.id, err.message, isRetryable(err)));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
failedEvent(workflowId, stageId, error, retryable) {
|
|
174
|
+
return {
|
|
175
|
+
type: 'action.failed',
|
|
176
|
+
workflow_id: workflowId,
|
|
177
|
+
stage_id: stageId,
|
|
178
|
+
error,
|
|
179
|
+
retryable,
|
|
180
|
+
timestamp: new Date().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// ========== 阶段视图(供 Tool 层消费) ==========
|
|
184
|
+
/** Pure payload builder for llm stages; no side effects, no events. */
|
|
185
|
+
triggerLLMStage(stage, state) {
|
|
186
|
+
return {
|
|
187
|
+
action: 'llm_generate',
|
|
188
|
+
stage_id: stage.id,
|
|
189
|
+
skill: stage.skill,
|
|
190
|
+
prompt: this.renderPrompt(stage.prompt_template, state),
|
|
191
|
+
inputs: (stage.inputs ?? []).map((p) => this.renderPath(p, state)),
|
|
192
|
+
outputs: (stage.outputs ?? []).map((p) => this.renderPath(p, state)),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Pure payload builder for gates; no side effects, no events. */
|
|
196
|
+
presentGate(stage, state) {
|
|
197
|
+
return {
|
|
198
|
+
action: 'gate_wait',
|
|
199
|
+
stage_id: stage.id,
|
|
200
|
+
checklist: stage.checklist ?? [],
|
|
201
|
+
prompt: this.renderPrompt(stage.gate_prompt, state),
|
|
202
|
+
decisions: stage.decisions ?? [],
|
|
203
|
+
artifacts: this.collectArtifacts(state),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Doc §6 view consumed by buildToolResponse: llm_generate / gate_wait / processing. */
|
|
207
|
+
getCurrentStageView(state) {
|
|
208
|
+
const stage = this.getStage(state.current_stage);
|
|
209
|
+
if (stage.type === 'llm_generation') {
|
|
210
|
+
return {
|
|
211
|
+
action: 'llm_generate',
|
|
212
|
+
workflow_id: state.workflow_id,
|
|
213
|
+
current_stage: stage.id,
|
|
214
|
+
skill: stage.skill,
|
|
215
|
+
prompt: this.renderPrompt(stage.prompt_template, state),
|
|
216
|
+
inputs: (stage.inputs ?? []).map((p) => this.renderPath(p, state)),
|
|
217
|
+
outputs: (stage.outputs ?? []).map((p) => this.renderPath(p, state)),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (stage.type === 'gate') {
|
|
221
|
+
const view = this.presentGate(stage, state);
|
|
222
|
+
return {
|
|
223
|
+
action: 'gate_wait',
|
|
224
|
+
workflow_id: state.workflow_id,
|
|
225
|
+
current_stage: stage.id,
|
|
226
|
+
checklist: view.checklist,
|
|
227
|
+
prompt: view.prompt,
|
|
228
|
+
decisions: view.decisions,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return { action: 'processing', workflow_id: state.workflow_id, current_stage: stage.id };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* D10: walk the definition backwards from the current gate to the nearest
|
|
235
|
+
* preceding llm_generation stage; prefer its RECORDED outputs from the
|
|
236
|
+
* stage.completed event, fall back to rendered stage.outputs templates.
|
|
237
|
+
*/
|
|
238
|
+
collectArtifacts(state) {
|
|
239
|
+
const idx = this.definition.stages.findIndex((s) => s.id === state.current_stage);
|
|
240
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
241
|
+
const stage = this.definition.stages[i];
|
|
242
|
+
if (stage.type !== 'llm_generation')
|
|
243
|
+
continue;
|
|
244
|
+
const recorded = state.stages[stage.id]?.output;
|
|
245
|
+
if (recorded && recorded.length > 0) {
|
|
246
|
+
return [...recorded];
|
|
247
|
+
}
|
|
248
|
+
return (stage.outputs ?? []).map((p) => this.renderPath(p, state));
|
|
249
|
+
}
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
// ========== 辅助方法 ==========
|
|
253
|
+
getStage(stageId) {
|
|
254
|
+
const stage = this.definition.stages.find((s) => s.id === stageId);
|
|
255
|
+
if (!stage)
|
|
256
|
+
throw new Error(`Unknown stage: ${stageId}`);
|
|
257
|
+
return stage;
|
|
258
|
+
}
|
|
259
|
+
renderPath(template, state) {
|
|
260
|
+
return renderStrict(template, state.context);
|
|
261
|
+
}
|
|
262
|
+
renderPrompt(template, state) {
|
|
263
|
+
if (!template)
|
|
264
|
+
return '';
|
|
265
|
+
return renderTemplate(template, state.context);
|
|
266
|
+
}
|
|
267
|
+
async requireWorkflow(workflowId) {
|
|
268
|
+
const state = await this.stateStore.load(workflowId);
|
|
269
|
+
if (!state) {
|
|
270
|
+
throw new Error(`Unknown workflow: ${workflowId}`);
|
|
271
|
+
}
|
|
272
|
+
return state;
|
|
273
|
+
}
|
|
274
|
+
requireCurrentStage(state, eventStageId) {
|
|
275
|
+
if (eventStageId !== state.current_stage) {
|
|
276
|
+
throw new Error(`Event stage_id ${eventStageId} does not match current stage ${state.current_stage} ` +
|
|
277
|
+
`(workflow ${state.workflow_id})`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ========== 启动恢复 (M6) ==========
|
|
281
|
+
async recoverWorkflows() {
|
|
282
|
+
const states = await this.stateStore.listAll();
|
|
283
|
+
for (const state of states) {
|
|
284
|
+
if (state.status !== 'running')
|
|
285
|
+
continue;
|
|
286
|
+
const stage = this.definition.stages.find((s) => s.id === state.current_stage);
|
|
287
|
+
if (!stage || stage.type !== 'action')
|
|
288
|
+
continue;
|
|
289
|
+
if (state.stages[stage.id]?.completed_at)
|
|
290
|
+
continue;
|
|
291
|
+
try {
|
|
292
|
+
// On-disk state is untrusted (cloned repos): validate context-derived
|
|
293
|
+
// path vars before letting any handler touch the filesystem.
|
|
294
|
+
validateContextVars(state.context);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
298
|
+
console.error(JSON.stringify({
|
|
299
|
+
type: 'workflow.blocked',
|
|
300
|
+
workflow_id: state.workflow_id,
|
|
301
|
+
stage: state.current_stage,
|
|
302
|
+
reason: 'invalid context',
|
|
303
|
+
error: message,
|
|
304
|
+
}));
|
|
305
|
+
state.status = 'blocked';
|
|
306
|
+
await this.stateStore.save(state);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// Idempotent handlers make a single re-execution safe.
|
|
310
|
+
await this.executeAction(stage, state);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { WorkflowState } from './types.js';
|
|
2
|
+
export interface WorkflowSummary {
|
|
3
|
+
workflow_id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
current_stage: string;
|
|
6
|
+
status: string;
|
|
7
|
+
last_active: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function summarize(state: WorkflowState): WorkflowSummary;
|
|
10
|
+
/**
|
|
11
|
+
* File-backed state store under {repoRoot}/.workflow:
|
|
12
|
+
* workflows/{id}.json — one file per workflow state
|
|
13
|
+
* index.json — array of workflow summaries
|
|
14
|
+
* All file writes go through tmp+rename so readers never observe partial files.
|
|
15
|
+
*/
|
|
16
|
+
export declare class WorkflowStore {
|
|
17
|
+
private readonly root;
|
|
18
|
+
private readonly workflowsDir;
|
|
19
|
+
private readonly indexPath;
|
|
20
|
+
constructor(repoRoot: string);
|
|
21
|
+
private statePath;
|
|
22
|
+
save(state: WorkflowState): Promise<void>;
|
|
23
|
+
/** Missing workflow → null; callers turn that into an "Unknown workflow" error.
|
|
24
|
+
* Containment is checked OUTSIDE the try so traversal ids throw, not return null. */
|
|
25
|
+
load(id: string): Promise<WorkflowState | null>;
|
|
26
|
+
listAll(): Promise<WorkflowState[]>;
|
|
27
|
+
/** Active = neither completed nor blocked. Derived from state files so the list self-heals. */
|
|
28
|
+
getActiveSummaries(): Promise<WorkflowSummary[]>;
|
|
29
|
+
updateIndex(state: WorkflowState): Promise<void>;
|
|
30
|
+
}
|