210 lines
6.0 KiB
JavaScript
210 lines
6.0 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import {
|
|
Annotation,
|
|
END,
|
|
START,
|
|
StateGraph,
|
|
} from '@langchain/langgraph';
|
|
import {
|
|
buildRunEvent,
|
|
normalizeRunSpec,
|
|
} from './contracts.mjs';
|
|
|
|
export const CODE_RUN_SHADOW_WORKFLOW = 'code-run-v1';
|
|
|
|
const ShadowState = Annotation.Root({
|
|
spec: Annotation(),
|
|
status: Annotation({
|
|
reducer: (_current, update) => update,
|
|
default: () => 'queued',
|
|
}),
|
|
phase: Annotation({
|
|
reducer: (_current, update) => update,
|
|
default: () => 'accepted',
|
|
}),
|
|
plan: Annotation({
|
|
reducer: (_current, update) => update,
|
|
default: () => null,
|
|
}),
|
|
result: Annotation({
|
|
reducer: (_current, update) => update,
|
|
default: () => null,
|
|
}),
|
|
events: Annotation({
|
|
reducer: (current, update) => [...current, ...update],
|
|
default: () => [],
|
|
}),
|
|
});
|
|
|
|
function executorIdentityForRun(runId) {
|
|
const normalizedRunId = String(runId ?? '').trim();
|
|
const jobId = `${normalizedRunId}:executor-preview`;
|
|
if (/^[a-zA-Z0-9:_-]+$/.test(normalizedRunId) && jobId.length <= 128) {
|
|
return {
|
|
jobId,
|
|
idempotencyKey: `${normalizedRunId}:build_plan:v1`,
|
|
};
|
|
}
|
|
const digest = crypto.createHash('sha256').update(normalizedRunId).digest('hex');
|
|
return {
|
|
jobId: `run_${digest}:executor-preview`,
|
|
idempotencyKey: `run_${digest}:build_plan:v1`,
|
|
};
|
|
}
|
|
|
|
function eventFor(state, type, data = null) {
|
|
return buildRunEvent({
|
|
runId: state.spec.runId,
|
|
sequence: state.events.length + 1,
|
|
type,
|
|
data,
|
|
});
|
|
}
|
|
|
|
function validateNode(state, { executionEnabled = false } = {}) {
|
|
const spec = normalizeRunSpec(state.spec);
|
|
if (spec.workflow.name !== CODE_RUN_SHADOW_WORKFLOW) {
|
|
const error = new Error(`Unsupported workflow: ${spec.workflow.name}`);
|
|
error.code = 'WORKFLOW_NOT_SUPPORTED';
|
|
throw error;
|
|
}
|
|
const observeOnly = spec.policy.executionMode === 'observe-only'
|
|
&& spec.policy.sideEffectsAllowed === false;
|
|
const active = spec.policy.executionMode === 'active'
|
|
&& spec.policy.sideEffectsAllowed === true
|
|
&& executionEnabled;
|
|
if (!observeOnly && !active) {
|
|
const error = new Error(
|
|
executionEnabled
|
|
? 'LangGraph run policy must be observe-only or explicitly active'
|
|
: 'LangGraph execution is disabled; only observe-only runs are accepted',
|
|
);
|
|
error.code = 'WORKFLOW_OBSERVE_ONLY_REQUIRED';
|
|
throw error;
|
|
}
|
|
return {
|
|
spec,
|
|
status: 'running',
|
|
phase: 'validated',
|
|
events: [eventFor(state, 'workflow_validated', {
|
|
workflow: spec.workflow,
|
|
executionMode: spec.policy.executionMode,
|
|
})],
|
|
};
|
|
}
|
|
|
|
async function planNode(state, executorGateway) {
|
|
const taskType = String(state.spec.input?.taskType ?? '').trim() || 'code-change';
|
|
const instruction = String(state.spec.input?.instruction ?? '');
|
|
const executorIdentity = executorIdentityForRun(state.spec.runId);
|
|
const active = state.spec.policy.executionMode === 'active';
|
|
const executor = String(state.spec.input?.executor ?? 'goosed').trim().toLowerCase();
|
|
const executorJob = await executorGateway.createJob({
|
|
jobId: executorIdentity.jobId,
|
|
idempotencyKey: executorIdentity.idempotencyKey,
|
|
executor,
|
|
task: {
|
|
type: taskType,
|
|
instruction,
|
|
workspaceRef: state.spec.input?.workspaceRef ?? null,
|
|
inputRefs: state.spec.input?.inputRefs ?? [],
|
|
},
|
|
subject: state.spec.subject,
|
|
authorization: {
|
|
executionAllowed: active,
|
|
actorId: state.spec.subject?.userId ?? null,
|
|
},
|
|
policy: {
|
|
sideEffectsAllowed: active && state.spec.policy.sideEffectsAllowed === true,
|
|
networkAllowed: active && state.spec.policy.networkAllowed === true,
|
|
},
|
|
controls: {
|
|
timeoutMs: state.spec.limits?.timeoutMs,
|
|
cancellationAllowed: true,
|
|
fallbackExecutor: 'aider',
|
|
maxAttempts: state.spec.limits?.maxAttempts,
|
|
},
|
|
metadata: {
|
|
source: 'langgraph-shadow-plan',
|
|
workflow: state.spec.workflow,
|
|
},
|
|
});
|
|
const plan = {
|
|
taskType,
|
|
executorAdapter: active ? 'executor-gateway' : 'native-agent-run',
|
|
executorJob: {
|
|
kind: 'executor-job',
|
|
id: executorJob.job.jobId,
|
|
executor: executorJob.job.executor,
|
|
status: executorJob.job.status,
|
|
reason: executorJob.job.reason,
|
|
durable: executorGateway.status().store.durable,
|
|
},
|
|
instructionCharacters: instruction.length,
|
|
steps: [
|
|
'accept_control_plane_run',
|
|
'project_executor_boundary',
|
|
'record_shadow_result',
|
|
],
|
|
};
|
|
return {
|
|
phase: 'planned',
|
|
plan,
|
|
events: [eventFor(state, 'workflow_planned', {
|
|
taskType,
|
|
executorAdapter: plan.executorAdapter,
|
|
executorJob: plan.executorJob,
|
|
stepCount: plan.steps.length,
|
|
})],
|
|
};
|
|
}
|
|
|
|
function finalizeNode(state) {
|
|
const queued = state.plan.executorJob.status === 'queued';
|
|
const result = {
|
|
observed: !queued,
|
|
executed: false,
|
|
executorAdapter: state.plan.executorAdapter,
|
|
taskType: state.plan.taskType,
|
|
};
|
|
return {
|
|
status: queued ? 'waiting' : 'succeeded',
|
|
phase: queued ? 'executor_queued' : 'completed',
|
|
result,
|
|
events: [eventFor(
|
|
state,
|
|
queued ? 'workflow_executor_queued' : 'workflow_completed',
|
|
result,
|
|
)],
|
|
};
|
|
}
|
|
|
|
export function createCodeRunShadowGraph({
|
|
checkpointer,
|
|
executorGateway,
|
|
executionEnabled = false,
|
|
} = {}) {
|
|
if (!checkpointer) throw new Error('Code run shadow graph requires a checkpointer');
|
|
if (!executorGateway?.createJob) {
|
|
throw new Error('Code run shadow graph requires an Executor Gateway');
|
|
}
|
|
return new StateGraph(ShadowState)
|
|
.addNode('validate_run', (state) => validateNode(state, { executionEnabled }))
|
|
.addNode('build_plan', (state) => planNode(state, executorGateway))
|
|
.addNode('finalize_run', finalizeNode)
|
|
.addEdge(START, 'validate_run')
|
|
.addEdge('validate_run', 'build_plan')
|
|
.addEdge('build_plan', 'finalize_run')
|
|
.addEdge('finalize_run', END)
|
|
.compile({ checkpointer });
|
|
}
|
|
|
|
export const codeRunShadowGraphInternals = {
|
|
ShadowState,
|
|
executorIdentityForRun,
|
|
eventFor,
|
|
validateNode,
|
|
planNode,
|
|
finalizeNode,
|
|
};
|