diff --git a/packages/architect-agent/package.json b/packages/architect-agent/package.json new file mode 100644 index 00000000..e4e71f5f --- /dev/null +++ b/packages/architect-agent/package.json @@ -0,0 +1,21 @@ +{ + "name": "@factory/architect-agent", + "version": "0.1.0", + "description": "Architect Agent Durable Object — Factory-wide governance singleton. SPEC-ARCHITECT-AGENT-DO-001", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@factory/knowing-state-sdk": "workspace:*", + "@factory/schemas": "workspace:*", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@cloudflare/workers-types": "^4.0.0" + } +} diff --git a/packages/architect-agent/src/architect-agent-do.ts b/packages/architect-agent/src/architect-agent-do.ts new file mode 100644 index 00000000..809249ce --- /dev/null +++ b/packages/architect-agent/src/architect-agent-do.ts @@ -0,0 +1,301 @@ +// Architect Agent Durable Object +// SPEC-ARCHITECT-AGENT-DO-001 +// Singleton — do-key: architect-agent:factory + +import type { + FactoryState, + CRPItem, + PatchRecord, + PipelineConfig, + RepoSummary, + CRPFailureClass, +} from './types.js' + +export type Env = { + ARANGO_URL: string + ARANGO_DB: string + ARANGO_TOKEN: string + WEOPS_GATEWAY_URL: string + HARNESS_BRIDGE_URL: string + COMPILER_URL: string + ANOMALY_SCAN_INTERVAL_MS: string + PATCH_PROPAGATION_TIMEOUT_MS: string + CRP_RESOLUTION_TIMEOUT_MS: string +} + +const DEFAULT_ANOMALY_INTERVAL = 15 * 60 * 1000 // 15 min +const DEFAULT_PATCH_TIMEOUT = 30 * 60 * 1000 // 30 min +const DEFAULT_CRP_TIMEOUT = 10 * 60 * 1000 // 10 min + +export class ArchitectAgentDO implements DurableObject { + constructor( + private readonly state: DurableObjectState, + private readonly env: Env + ) {} + + async fetch(request: Request): Promise { + const url = new URL(request.url) + const method = request.method + + if (method === 'POST' && url.pathname === '/crp') return this.handleCRP(request) + if (method === 'POST' && url.pathname === '/patch') return this.handlePatch(request) + if (method === 'POST' && url.pathname === '/register-repo') return this.handleRegisterRepo(request) + if (method === 'POST' && url.pathname === '/deregister-repo') return this.handleDeregisterRepo(request) + if (method === 'POST' && url.pathname === '/pipeline-config-auth') return this.handlePipelineConfigAuth(request) + if (method === 'POST' && url.pathname === '/override') return this.handleOverride(request) + if (method === 'GET' && url.pathname === '/health') return this.handleHealth() + if (method === 'GET' && url.pathname === '/pipeline-config') return this.handleGetPipelineConfig() + + return new Response('Not found', { status: 404 }) + } + + async alarm(): Promise { + await this.runAnomalyScan() + await this.checkPatchPropagation() + // Re-arm + await this.state.storage.setAlarm( + Date.now() + Number(this.env.ANOMALY_SCAN_INTERVAL_MS ?? DEFAULT_ANOMALY_INTERVAL) + ) + } + + // ── D2: CRP Resolution ────────────────────────────────────────────────── + + private async handleCRP(request: Request): Promise { + const body = await request.json() as { + repoId: string + amendmentId: string + coherenceVerdictDetail: string + hypothesisId: string + divergenceIds: string[] + } + + const crpId = `CRP-${body.repoId}-${Date.now()}` + const item: CRPItem = { + crpId, + repoId: body.repoId, + amendmentId: body.amendmentId, + coherenceVerdict: body.coherenceVerdictDetail, + status: 'pending', + receivedAt: new Date().toISOString(), + } + + // Add to CRP queue + const queue = (await this.state.storage.get('crp:queue')) ?? [] + queue.push(item) + await this.state.storage.put('crp:queue', queue) + + // Attempt synchronous resolution + const failureClass = await this.classifyCRPFailure(body.coherenceVerdictDetail) + const resolved = await this.resolveCRP(crpId, failureClass, body) + + return Response.json({ crpId, status: resolved ? 'resolved' : 'in-resolution' }) + } + + private async classifyCRPFailure(verdictDetail: string): Promise { + if (verdictDetail.includes('schema') || verdictDetail.includes('unknown type')) return 'SCHEMA_VIOLATION' + if (verdictDetail.includes('invariant') || verdictDetail.includes('conflict')) return 'INVARIANT_CONFLICT' + if (verdictDetail.includes('coverage') || verdictDetail.includes('detector')) return 'COVERAGE_GAP' + if (verdictDetail.includes('lineage') || verdictDetail.includes('edge')) return 'LINEAGE_BREAK' + return 'UNKNOWN' + } + + private async resolveCRP( + crpId: string, + failureClass: CRPFailureClass, + context: { repoId: string; amendmentId: string; coherenceVerdictDetail: string } + ): Promise { + // TODO: implement resolution paths per SPEC §2.2 + // SCHEMA_VIOLATION → emit corrected AMD-* diff + // INVARIANT_CONFLICT → check cross-repo; open Patch if so + // COVERAGE_GAP → emit missing DetectorSpec + // LINEAGE_BREAK → reconstruct missing edge + // UNKNOWN → escalate to We-layer + if (failureClass === 'UNKNOWN') { + await this.escalateToWeLayer('CRPFail', { crpId, ...context }) + return false + } + return false // TODO: implement + } + + // ── D1: Patch Governance ──────────────────────────────────────────────── + + private async handlePatch(request: Request): Promise { + const body = await request.json() as { + changedArtifactId: string + changeDescription: string + authorizedBy: string + urgency: 'normal' | 'emergency' + } + + const patchId = `PATCH-${Date.now()}` + + // TODO: AQL traversal to find affected repos + // TODO: Coherence Verification per repo + // TODO: propagate to Commissioning Agents + + const patch: PatchRecord = { + patchId, + trigger: body.changedArtifactId, + affectedRepoIds: [], // populated after AQL traversal + appliedToRepoIds: [], + pendingRepoIds: [], + status: 'propagating', + issuedAt: new Date().toISOString(), + } + + const registry = (await this.state.storage.get('patches:active')) ?? [] + registry.push(patch) + await this.state.storage.put('patches:active', registry) + + return Response.json({ patchId, affectedRepoCount: 0, status: 'propagating' }) + } + + // ── D4: Pipeline Config Auth ──────────────────────────────────────────── + + private async handlePipelineConfigAuth(request: Request): Promise { + const body = await request.json() as { + proposedConfigId: string + affectedLiveRepoIds: string[] + authorizedBy: string + dispositionEventId: string + } + + // TODO: fetch PIPELINE-CONFIG-* from ArangoDB + // TODO: apply to DO hot storage + // TODO: notify harness-bridge of updated routing + + return Response.json({ status: 'applied', configId: body.proposedConfigId }) + } + + // ── Override ─────────────────────────────────────────────────────────── + + private async handleOverride(request: Request): Promise { + const body = await request.json() as { action: string; authorizedBy: string } + + // TODO: validate elevated token scope (we-layer:override) + + const factoryState = await this.factoryState() + factoryState.lifecycleState = body.action === 'force-suspend' ? 'EMERGENCY_SUSPEND' : 'ACTIVE' + await this.state.storage.put('factory:state', factoryState) + + return Response.json({ status: 'override-applied' }) + } + + // ── Register / Deregister ────────────────────────────────────────────── + + private async handleRegisterRepo(request: Request): Promise { + const body = await request.json() as { repoId: string; commissioningAgentUrl: string; mediationAgentDoKey: string } + + const state = await this.factoryState() + const exists = state.activeRepos.find(r => r.repoId === body.repoId) + if (!exists) { + state.activeRepos.push({ + repoId: body.repoId, + commissioningAgentUrl: body.commissioningAgentUrl, + mediationAgentDoKey: body.mediationAgentDoKey, + lastHealthPollAt: new Date().toISOString(), + healthStatus: 'unknown', + activeBlockingDivergences: 0, + pendingCrpCount: 0, + }) + await this.state.storage.put('factory:state', state) + } + + return Response.json({ status: 'registered' }) + } + + private async handleDeregisterRepo(request: Request): Promise { + const body = await request.json() as { repoId: string } + const state = await this.factoryState() + state.activeRepos = state.activeRepos.filter(r => r.repoId !== body.repoId) + await this.state.storage.put('factory:state', state) + return Response.json({ status: 'deregistered' }) + } + + // ── Health / Pipeline config ─────────────────────────────────────────── + + private async handleHealth(): Promise { + const state = await this.factoryState() + const crpQueue = (await this.state.storage.get<{ items: { status: string }[] }>('crp:queue')) ?? { items: [] } + const patches = (await this.state.storage.get('patches:active')) ?? [] + + return Response.json({ + factoryLifecycleState: state.lifecycleState, + activeRepoCount: state.activeRepos.length, + repoHealthBreakdown: { + healthy: state.activeRepos.filter(r => r.healthStatus === 'healthy').length, + degraded: state.activeRepos.filter(r => r.healthStatus === 'degraded').length, + suspended: state.activeRepos.filter(r => r.healthStatus === 'suspended').length, + unknown: state.activeRepos.filter(r => r.healthStatus === 'unknown').length, + }, + openEscalationCount: 0, // TODO: read from ArangoDB + activePatchCount: patches.filter(p => p.status === 'propagating').length, + pendingCrpCount: crpQueue.items?.filter(i => i.status === 'pending').length ?? 0, + pipelineConfigId: state.pipelineConfig?.configId ?? 'default', + }) + } + + private async handleGetPipelineConfig(): Promise { + const state = await this.factoryState() + return Response.json(state.pipelineConfig) + } + + // ── Cross-repo anomaly scan (alarm) ──────────────────────────────────── + + private async runAnomalyScan(): Promise { + // TODO: AQL query for cross-repo failure patterns (SPEC §4) + // Pattern thresholds: + // pass failure rate > 15% across 3+ repos in 1h → D4 trigger + // Amendment Coherence failures > 5 in 1h → D2 triage + // Patch stalled > 30 min → D1 escalation check + } + + private async checkPatchPropagation(): Promise { + // TODO: check active patches for timeout; escalate if stalled + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private async factoryState(): Promise { + return (await this.state.storage.get('factory:state')) ?? { + activeRepos: [], + pipelineConfig: defaultPipelineConfig(), + lifecycleState: 'ACTIVE', + } + } + + private async escalateToWeLayer(type: string, evidence: unknown): Promise { + await fetch(`${this.env.WEOPS_GATEWAY_URL}/escalations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + signalType: 'EscalationEvent', + sourceAgent: 'ArchitectAgentDO', + sourceId: 'factory', + escalationType: type, + evidence, + issuedAt: new Date().toISOString(), + }), + }) + } +} + +function defaultPipelineConfig(): PipelineConfig { + return { + configId: 'default', + passRouting: [], + gateThresholds: { + coherenceMinCoverage: 1.0, + fidelityMaxOpenBlockingDivergences: 0, + assuranceMaxDetectorStalenessHours: 24, + }, + verticalSlicePolicy: { + atomRetryIsolation: true, + maxAtomRetries: 3, + parallelSliceThreshold: 4, + dagDispatchEnabled: false, + }, + effectiveFrom: new Date().toISOString(), + reason: 'default', + } +} diff --git a/packages/architect-agent/src/index.ts b/packages/architect-agent/src/index.ts new file mode 100644 index 00000000..282ffe25 --- /dev/null +++ b/packages/architect-agent/src/index.ts @@ -0,0 +1,3 @@ +export { ArchitectAgentDO } from './architect-agent-do.js' +export type { Env } from './architect-agent-do.js' +export type { FactoryState, CRPItem, PatchRecord, PipelineConfig, VerticalSlicePolicy, RepoSummary, CRPFailureClass } from './types.js' diff --git a/packages/architect-agent/src/types.ts b/packages/architect-agent/src/types.ts new file mode 100644 index 00000000..77aa204e --- /dev/null +++ b/packages/architect-agent/src/types.ts @@ -0,0 +1,75 @@ +// Architect Agent DO — factory state and decision domain types +// SPEC-ARCHITECT-AGENT-DO-001 + +export type RepoSummary = { + repoId: string + commissioningAgentUrl: string + mediationAgentDoKey: string + lastHealthPollAt: string + healthStatus: 'healthy' | 'degraded' | 'suspended' | 'unknown' + activeBlockingDivergences: number + pendingCrpCount: number +} + +export type VerticalSlicePolicy = { + atomRetryIsolation: boolean + maxAtomRetries: number + parallelSliceThreshold: number + dagDispatchEnabled: boolean +} + +export type PassRoutingConfig = { + passId: string + model: 'gpt-5-5' | 'deepseek-flash' | 'claude-opus' | 'local' + fallback: 'gpt-5-5' | 'claude-opus' + maxRetries: number +} + +export type GateThresholdConfig = { + coherenceMinCoverage: number + fidelityMaxOpenBlockingDivergences: number + assuranceMaxDetectorStalenessHours: number +} + +export type PipelineConfig = { + configId: string + passRouting: PassRoutingConfig[] + gateThresholds: GateThresholdConfig + verticalSlicePolicy: VerticalSlicePolicy + effectiveFrom: string + reason: string +} + +export type FactoryState = { + activeRepos: RepoSummary[] + pipelineConfig: PipelineConfig + lifecycleState: 'ACTIVE' | 'EMERGENCY_SUSPEND' | 'MAINTENANCE' +} + +export type CRPItem = { + crpId: string + repoId: string + amendmentId: string + coherenceVerdict: string + status: 'pending' | 'in-resolution' | 'resolved' | 'escalated-to-we-layer' + receivedAt: string + resolvedAt?: string +} + +export type PatchRecord = { + patchId: string + trigger: string + affectedRepoIds: string[] + appliedToRepoIds: string[] + pendingRepoIds: string[] + status: 'propagating' | 'complete' | 'partial-failure' + issuedAt: string + completedAt?: string +} + +export type CRPFailureClass = + | 'SCHEMA_VIOLATION' + | 'INVARIANT_CONFLICT' + | 'COVERAGE_GAP' + | 'LINEAGE_BREAK' + | 'UNKNOWN' // fallback — escalates to We-layer by default diff --git a/packages/architect-agent/tsconfig.json b/packages/architect-agent/tsconfig.json new file mode 100644 index 00000000..911bc16c --- /dev/null +++ b/packages/architect-agent/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/packages/commissioning-agent/package.json b/packages/commissioning-agent/package.json new file mode 100644 index 00000000..33821f12 --- /dev/null +++ b/packages/commissioning-agent/package.json @@ -0,0 +1,21 @@ +{ + "name": "@factory/commissioning-agent", + "version": "0.1.0", + "description": "Commissioning Agent — per-repo spec-execution governance loop. SPEC-COMMISSIONING-AGENT-001", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@factory/knowing-state-sdk": "workspace:*", + "@factory/schemas": "workspace:*", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@cloudflare/workers-types": "^4.0.0" + } +} diff --git a/packages/commissioning-agent/src/commissioning-agent.ts b/packages/commissioning-agent/src/commissioning-agent.ts new file mode 100644 index 00000000..c552d1a9 --- /dev/null +++ b/packages/commissioning-agent/src/commissioning-agent.ts @@ -0,0 +1,169 @@ +// Commissioning Agent — stateless CF Worker +// SPEC-COMMISSIONING-AGENT-001 +// One instance per repo. All governance state in ArangoDB. + +import type { CommissioningSignal, EscalationEvent } from './types.js' + +export type Env = { + ARANGO_URL: string + ARANGO_DB: string + ARANGO_TOKEN: string + MEDIATION_AGENT: DurableObjectNamespace + WEOPS_GATEWAY_URL: string + HARNESS_BRIDGE_URL: string + AUTO_SUSPEND_THRESHOLD_CYCLES: string + POLL_INTERVAL_ACTIVE_MS: string + POLL_INTERVAL_IDLE_MS: string +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) + const method = request.method + + if (method === 'POST' && url.pathname === '/commission') return handleCommission(request, env) + if (method === 'POST' && url.pathname === '/resume') return handleResume(request, env) + if (method === 'POST' && url.pathname === '/override') return handleOverride(request, env) + if (method === 'GET' && url.pathname.startsWith('/health/')) return handleHealth(request, env) + + return new Response('Not found', { status: 404 }) + }, +} + +// ── R1: Commission flow ───────────────────────────────────────────────────── + +async function handleCommission(request: Request, env: Env): Promise { + const signal = await request.json() as CommissioningSignal + + // TODO: validate WeOpsDispositionToken (SPEC-WEOPS-GATEWAY-BOUNDARY-001 §4) + // TODO: verify Coherence Verdict on WG-* in ArangoDB + // TODO: produce ElucidationArtifact (A9 — Axiom A9) + // TODO: write CommissionRecord to ArangoDB + + // Commission the Mediation Agent DO + const doId = env.MEDIATION_AGENT.idFromName(`mediation-agent:${signal.repoId}`) + const doStub = env.MEDIATION_AGENT.get(doId) + + const doResponse = await doStub.fetch('https://do/commission', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workGraphId: signal.workGraphId, + workGraphVersion: signal.workGraphVersion, + arangoLineageRefs: [signal.workGraphId], + }), + }) + + const result = await doResponse.json() as { status: string; policyBeadId?: string } + + if (result.status !== 'commissioned') { + // TODO: escalate to We-layer (CommissionFail) + return Response.json({ status: 'error', reason: result.status }, { status: 502 }) + } + + // TODO: write VCR to ArangoDB + // TODO: start polling loop (CF Workflow or Cron Trigger) + + return Response.json({ status: 'commissioned', commissionRecordId: `CMR-${signal.repoId}-${Date.now()}` }) +} + +// ── R5: Resume ────────────────────────────────────────────────────────────── + +async function handleResume(request: Request, env: Env): Promise { + const body = await request.json() as { repoId: string; newWorkGraphId?: string; newWorkGraphVersion?: string; authorizedBy: string; dispositionArtifactId: string } + + // Resume always triggers a new Commission flow + const commissionSignal: CommissioningSignal = { + signalType: 'CommissioningSignal', + repoId: body.repoId, + workGraphId: body.newWorkGraphId ?? '', + workGraphVersion: body.newWorkGraphVersion ?? '', + commissionedBy: body.authorizedBy, + dispositionEventId: body.dispositionArtifactId, + elucidationArtifactId: body.dispositionArtifactId, + issuedAt: new Date().toISOString(), + } + + const fakeRequest = new Request('https://internal/commission', { + method: 'POST', + body: JSON.stringify(commissionSignal), + }) + return handleCommission(fakeRequest, env) +} + +// ── Override (Architect Agent emergency) ─────────────────────────────────── + +async function handleOverride(request: Request, env: Env): Promise { + const body = await request.json() as { repoId: string; action: string; authorizedBy: string } + + // TODO: validate elevated WeOpsDispositionToken scope (we-layer:override) + + const doId = env.MEDIATION_AGENT.idFromName(`mediation-agent:${body.repoId}`) + const doStub = env.MEDIATION_AGENT.get(doId) + + if (body.action === 'force-suspend') { + await doStub.fetch('https://do/suspend', { method: 'POST', body: JSON.stringify(body) }) + } + + return Response.json({ status: 'override-applied' }) +} + +// ── Health ────────────────────────────────────────────────────────────────── + +async function handleHealth(request: Request, env: Env): Promise { + const repoId = new URL(request.url).pathname.split('/health/')[1] + + const doId = env.MEDIATION_AGENT.idFromName(`mediation-agent:${repoId}`) + const doStub = env.MEDIATION_AGENT.get(doId) + + const stateResponse = await doStub.fetch('https://do/state') + const state = await stateResponse.json() + + return Response.json(state) +} + +// ── R3: Hypothesis formation ──────────────────────────────────────────────── +// Invoked from polling loop (CF Workflow — not in this Worker) +// Model: Claude Opus via @factory/harness-bridge + +export async function formHypothesis( + divergenceId: string, + repoId: string, + env: Env +): Promise { + // TODO: read Divergence from ArangoDB + // TODO: call harness-bridge with Claude Opus for structured Hypothesis output + // TODO: write HYP-* artifact to ArangoDB with lineage edges + // TODO: if blocking: trigger proposeAmendment() + return `HYP-${repoId}-${Date.now()}` +} + +// ── R4: Amendment proposal ───────────────────────────────────────────────── +// Model: GPT-5.5 via @factory/harness-bridge + +export async function proposeAmendment( + hypothesisId: string, + repoId: string, + env: Env +): Promise { + // TODO: translate Hypothesis corrective response into WorkGraph diff + // TODO: write AMD-* artifact to ArangoDB + // TODO: submit to compiler for Coherence Verification + // TODO: on favorable: trigger commission flow with successor WG-* + // TODO: on unfavorable: escalate to We-layer + return `AMD-${repoId}-${Date.now()}` +} + +// ── R5: Escalation ───────────────────────────────────────────────────────── + +export async function escalateToWeLayer( + event: EscalationEvent, + env: Env +): Promise { + // TODO: write ESC-* to ArangoDB before send + await fetch(`${env.WEOPS_GATEWAY_URL}/escalations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }) +} diff --git a/packages/commissioning-agent/src/index.ts b/packages/commissioning-agent/src/index.ts new file mode 100644 index 00000000..65dcedf7 --- /dev/null +++ b/packages/commissioning-agent/src/index.ts @@ -0,0 +1,3 @@ +export { default, formHypothesis, proposeAmendment, escalateToWeLayer } from './commissioning-agent.js' +export type { Env } from './commissioning-agent.js' +export type { CommissionRecord, ElucidationArtifact, VCR, CommissioningSignal, EscalationEvent } from './types.js' diff --git a/packages/commissioning-agent/src/types.ts b/packages/commissioning-agent/src/types.ts new file mode 100644 index 00000000..4fe180fd --- /dev/null +++ b/packages/commissioning-agent/src/types.ts @@ -0,0 +1,69 @@ +// Commissioning Agent — artifact and signal types +// SPEC-COMMISSIONING-AGENT-001 + +export type CommissionRecord = { + _key: string // CMR-{repoId}-{timestamp} + repoId: string + workGraphId: string + workGraphVersion: string + commissionedAt: string + mediationAgentDoKey: string + elucidationArtifactId: string + status: 'success' | 'error' + errorReason?: string + source: 'commissioning-agent' + explicitness: 'stated' +} + +export type ElucidationArtifact = { + _key: string // ELC-CMR-{repoId}-{timestamp} + dispositionEventType: 'commission' | 'amendment-adoption' | 'suspension' | 'resumption' + candidateSet: { workGraphVersions: string[] } + selectedOption: string + rejectedOptions: Array<{ workGraphVersion: string; rejectionReason: string }> + constraintsApplied: string[] + producedAt: string + producedBy: string + source: 'commissioning-agent' + explicitness: 'stated' +} + +export type VCR = { + _key: string + dispositionEventType: string + repoId: string + workGraphVersion?: string + verdict: 'favorable' | 'unfavorable' + verdictSource: 'coherence-verification' | 'fidelity-verification' | 'we-layer-override' + producedAt: string + linkedArtifacts: string[] + source: 'commissioning-agent' + explicitness: 'stated' +} + +export type CommissioningSignal = { + signalType: 'CommissioningSignal' + repoId: string + workGraphId: string + workGraphVersion: string + commissionedBy: string + dispositionEventId: string + elucidationArtifactId: string + issuedAt: string +} + +export type EscalationEvent = { + signalType: 'EscalationEvent' + escalationId: string + sourceAgent: 'CommissioningAgent' + sourceId: string + escalationType: 'AutoSuspend' | 'AmendmentCoherenceFail' | 'CommissionFail' + evidence: { + divergenceIds?: string[] + hypothesisId?: string + amendmentId?: string + coherenceVerdictDetail?: string + } + requestedAction: string + issuedAt: string +} diff --git a/packages/commissioning-agent/tsconfig.json b/packages/commissioning-agent/tsconfig.json new file mode 100644 index 00000000..911bc16c --- /dev/null +++ b/packages/commissioning-agent/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/packages/conducting-agent/package.json b/packages/conducting-agent/package.json new file mode 100644 index 00000000..472ae7f5 --- /dev/null +++ b/packages/conducting-agent/package.json @@ -0,0 +1,21 @@ +{ + "name": "@factory/conducting-agent", + "version": "0.1.0", + "description": "Conducting Agent — execution substrate interface (Gas City + CF Sandboxes). SPEC-CONDUCTING-AGENT-001", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@factory/knowing-state-sdk": "workspace:*", + "@factory/schemas": "workspace:*", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@cloudflare/workers-types": "^4.0.0" + } +} diff --git a/packages/conducting-agent/src/conducting-agent.ts b/packages/conducting-agent/src/conducting-agent.ts new file mode 100644 index 00000000..eb7144b5 --- /dev/null +++ b/packages/conducting-agent/src/conducting-agent.ts @@ -0,0 +1,199 @@ +// Conducting Agent — stateless CF Worker +// SPEC-CONDUCTING-AGENT-001 +// No Factory ontology awareness. Receives AtomDirective, runs session, reports trace. + +import { AtomDirective } from '@factory/schemas' +import { SessionNotInitialized } from '@factory/knowing-state-sdk' +import type { GasCitySessionRequest, GasCitySessionResponse, ConductingAgentTraceFragment } from './types.js' + +export type Env = { + GAS_CITY_SUPERVISOR_URL: string + MEDIATION_AGENT: DurableObjectNamespace + SANDBOX_OUTPUT_BUCKET: R2Bucket + VERTICAL_SLICE_MAX_PARALLEL: string +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) + + if (request.method === 'POST' && url.pathname === '/execute') { + return handleExecute(request, env) + } + + return new Response('Not found', { status: 404 }) + }, +} + +// ── Main execution handler ────────────────────────────────────────────────── + +async function handleExecute(request: Request, env: Env): Promise { + const body = await request.json() as { repoId: string; executionId: string; sessionId: string } + + // I2 — Retrieval enforcement: pull AtomDirective from Mediation Agent + const doId = env.MEDIATION_AGENT.idFromName(`mediation-agent:${body.repoId}`) + const doStub = env.MEDIATION_AGENT.get(doId) + + const dispatchResponse = await doStub.fetch('https://do/dispatch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ executionId: body.executionId, sessionId: body.sessionId }), + }) + + const dispatched = await dispatchResponse.json() as { status: string; atom?: unknown } + + if (dispatched.status === 'blocked') { + return Response.json({ status: 'blocked', reason: 'mediation-agent-not-active' }) + } + if (dispatched.status === 'complete') { + return Response.json({ status: 'complete' }) + } + + // Validate AtomDirective schema + const parseResult = AtomDirective.safeParse(dispatched.atom) + if (!parseResult.success) { + await reportTrace(doStub, { + executionId: body.executionId, + directiveId: 'unknown', + atomRef: 'unknown', + workGraphVersion: 'unknown', + repoId: body.repoId, + outcome: 'failure', + rawOutput: JSON.stringify(parseResult.error.issues), + durationMs: 0, + attemptNumber: 1, + producedAt: new Date().toISOString(), + }) + return Response.json({ status: 'error', reason: 'invalid-directive' }) + } + + const directive = parseResult.data + + // Execute with retry loop + const trace = await executeWithRetry(directive, body.executionId, body.repoId, env) + + // Report to Mediation Agent + await reportTrace(doStub, trace) + + return Response.json({ status: 'executed', outcome: trace.outcome }) +} + +// ── Execution loop with vertical slice retry ──────────────────────────────── + +async function executeWithRetry( + directive: AtomDirective, + executionId: string, + repoId: string, + env: Env +): Promise { + const { maxAttempts, backoffMs, isolatedRetry } = directive.retryPolicy + let lastTrace: ConductingAgentTraceFragment | null = null + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (attempt > 1) await sleep(backoffMs) + + const sessionResult = await runGasCitySession(directive, env) + + const rawOutput = sessionResult.stdout.slice(0, 4096) // post_execution.ts hook + const fullOutput = sessionResult.stdout.length > 4096 + ? await storeFullOutput(sessionResult.stdout, directive.directiveId, env) + : undefined + + const success = evaluateSuccessCondition(directive.successCondition, sessionResult) + const outcome = sessionResult.outcome === 'timeout' ? 'timeout' + : success ? 'success' + : 'failure' + + lastTrace = { + executionId, + directiveId: directive.directiveId, + atomRef: directive.atomRef, + workGraphVersion: directive.workGraphVersion, + repoId, + outcome, + rawOutput, + ...(fullOutput !== undefined ? { sandboxOutputRef: fullOutput } : {}), + durationMs: sessionResult.durationMs, + attemptNumber: attempt, + producedAt: new Date().toISOString(), + } + + if (outcome === 'success') return lastTrace + + // on_failure.ts hook: capture sandbox state + // TODO: capture filesystem diff, stderr, exit code + + if (!isolatedRetry || attempt >= maxAttempts) break + } + + if (!lastTrace) throw new Error('executeWithRetry: no trace produced — maxAttempts must be >= 1') + return lastTrace +} + +// ── Gas City session ──────────────────────────────────────────────────────── + +async function runGasCitySession( + directive: AtomDirective, + env: Env +): Promise { + const sessionReq: GasCitySessionRequest = { + repoId: directive.repoId, + workingDir: directive.workingDir, + memoryMb: directive.sandboxConfig.memoryMb, + envVars: directive.envVars, + permittedTools: directive.permittedTools, + instruction: directive.instruction, + timeoutMs: directive.timeoutMs, + } + + const response = await fetch(`${env.GAS_CITY_SUPERVISOR_URL}/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sessionReq), + }) + + return response.json() as Promise +} + +// ── SuccessCondition evaluation ───────────────────────────────────────────── +// Deterministic — no LLM involvement (SPEC-CONDUCTING-AGENT-001 §2.1 step 5) + +function evaluateSuccessCondition( + condition: AtomDirective['successCondition'], + result: GasCitySessionResponse +): boolean { + switch (condition.type) { + case 'exit-code': + return result.exitCode === condition.expectedCode + case 'output-contains': + return result.stdout.includes(condition.substring) + case 'output-matches': + return new RegExp(condition.pattern).test(result.stdout) + case 'file-exists': + // TODO: query sandbox filesystem + return false + case 'composite': + return condition.all.every(c => evaluateSuccessCondition(c, result)) + } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +async function reportTrace(doStub: DurableObjectStub, trace: ConductingAgentTraceFragment): Promise { + await doStub.fetch('https://do/trace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(trace), + }) +} + +async function storeFullOutput(output: string, directiveId: string, env: Env): Promise { + const key = `sandbox-output/${directiveId}/${Date.now()}.txt` + await env.SANDBOX_OUTPUT_BUCKET.put(key, output) + // TODO: generate presigned URL with 24h TTL + return `r2://${key}` +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/packages/conducting-agent/src/index.ts b/packages/conducting-agent/src/index.ts new file mode 100644 index 00000000..c4c44525 --- /dev/null +++ b/packages/conducting-agent/src/index.ts @@ -0,0 +1,3 @@ +export { default } from './conducting-agent.js' +export type { Env } from './conducting-agent.js' +export type { GasCitySessionRequest, GasCitySessionResponse, ConductingAgentTraceFragment } from './types.js' diff --git a/packages/conducting-agent/src/types.ts b/packages/conducting-agent/src/types.ts new file mode 100644 index 00000000..74f90a5c --- /dev/null +++ b/packages/conducting-agent/src/types.ts @@ -0,0 +1,36 @@ +// Conducting Agent — Gas City interface types +// SPEC-CONDUCTING-AGENT-001 §3, §4 + +export type GasCitySessionRequest = { + repoId: string + workingDir: string + memoryMb: number + envVars: Record + permittedTools: string[] + instruction: string + timeoutMs: number +} + +export type GasCitySessionResponse = { + sessionId: string + outcome: 'success' | 'failure' | 'timeout' + stdout: string + stderr: string + exitCode: number + durationMs: number +} + +export type ConductingAgentTraceFragment = { + executionId: string + directiveId: string + atomRef: string + workGraphVersion: string + repoId: string + outcome: 'success' | 'failure' | 'timeout' | 'cancelled' + rawOutput: string // truncated to 4KB + sandboxOutputRef?: string // presigned R2 URL, 24h TTL + durationMs: number + attemptNumber: number + cancelReason?: string + producedAt: string +} diff --git a/packages/conducting-agent/tsconfig.json b/packages/conducting-agent/tsconfig.json new file mode 100644 index 00000000..911bc16c --- /dev/null +++ b/packages/conducting-agent/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/packages/knowing-state-sdk/package.json b/packages/knowing-state-sdk/package.json new file mode 100644 index 00000000..1e7533e1 --- /dev/null +++ b/packages/knowing-state-sdk/package.json @@ -0,0 +1,18 @@ +{ + "name": "@factory/knowing-state-sdk", + "version": "0.1.0", + "description": "Domain-agnostic Knowing-State Prosthesis SDK. Provisional name — will move to @koales/knowing-state-sdk when cross-product infrastructure decision is made.", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@factory/schemas": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.4.0" + } +} diff --git a/packages/knowing-state-sdk/src/index.ts b/packages/knowing-state-sdk/src/index.ts new file mode 100644 index 00000000..bfa27c6a --- /dev/null +++ b/packages/knowing-state-sdk/src/index.ts @@ -0,0 +1,3 @@ +export type { BeadId, BeadEnvelope, KnowingState, AmendmentBead } from './types.js' +export type { KnowingStateSDK } from './sdk.js' +export { SessionNotInitialized } from './sdk.js' diff --git a/packages/knowing-state-sdk/src/sdk.ts b/packages/knowing-state-sdk/src/sdk.ts new file mode 100644 index 00000000..cb74ff2d --- /dev/null +++ b/packages/knowing-state-sdk/src/sdk.ts @@ -0,0 +1,58 @@ +// Knowing-State Prosthesis SDK — generic interface +// SPEC-MEDIATION-AGENT-DO-001 §8, DECISIONS N+3 +// +// Type parameters: +// PolicyContent — domain-specific policy/specification content +// TrustContent — domain-specific trust/verdict state +// ExecutionContent — domain-specific execution record payload +// OutcomeContent — domain-specific outcome record payload +// +// Instantiations: +// Factory repo DO: Policy=ArchitectureDecisionBead, Trust=PatternTrustBead, +// Execution=CommitBead, Outcome=BuildOutcomeBead +// ComeFlow commerce: Policy=OrgPreferenceBead, Trust=VendorTrustBead, +// Execution=PurchaseBead, Outcome=OutcomeBead + +import type { BeadId, KnowingState, AmendmentBead } from './types.js' + +export class SessionNotInitialized extends Error { + constructor(message = 'retrieveKnowingState() must be called before execution') { + super(message) + this.name = 'SessionNotInitialized' + } +} + +export interface KnowingStateSDK< + PolicyContent, + TrustContent, + ExecutionContent, + OutcomeContent, +> { + /** + * I2 — Retrieval enforcement. + * Must be called before any execution write. + * Throws SessionNotInitialized if called without valid session context. + */ + retrieveKnowingState(category: string): Promise> + + /** + * Write an execution record. The executing agent is the primary writer. + * Returns the BeadId of the written ExecutionBead. + */ + writeExecutionBead(payload: ExecutionContent): Promise + + /** + * Write an outcome record. Closes the execution loop. + * Returns the BeadId of the written OutcomeBead. + */ + writeOutcomeBead( + executionBeadId: BeadId, + outcome: OutcomeContent + ): Promise + + /** + * Retrieve open amendments (proposed modifications to the governing policy). + * Consumed by the Commissioning Agent / governance layer. + */ + getOpenAmendments(): Promise[]> +} diff --git a/packages/knowing-state-sdk/src/types.ts b/packages/knowing-state-sdk/src/types.ts new file mode 100644 index 00000000..126356f1 --- /dev/null +++ b/packages/knowing-state-sdk/src/types.ts @@ -0,0 +1,30 @@ +// Knowing-State Prosthesis SDK — domain-agnostic types +// SPEC-MEDIATION-AGENT-DO-001 §8, DECISIONS N+3 + +export type BeadId = string + +export type BeadEnvelope = { + _key: string + beadType: string + createdAt: string + immutable: true + source: string + explicitness: 'stated' | 'inferred' +} + +export type KnowingState = { + policyBeadId: BeadId + trustContent: TrustContent + lifecycleState: string + coherenceVerdict: { + value: 'favorable' | 'unfavorable' | 'pending' + reason?: string + } +} + +export type AmendmentBead = BeadEnvelope & { + beadType: 'AmendmentBead' + proposedModification: PolicyContent + motivatedBy: BeadId + status: 'open' | 'adopted' | 'rejected' +} diff --git a/packages/knowing-state-sdk/tsconfig.json b/packages/knowing-state-sdk/tsconfig.json new file mode 100644 index 00000000..792172fb --- /dev/null +++ b/packages/knowing-state-sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/mediation-agent/package.json b/packages/mediation-agent/package.json new file mode 100644 index 00000000..3a1e9b94 --- /dev/null +++ b/packages/mediation-agent/package.json @@ -0,0 +1,21 @@ +{ + "name": "@factory/mediation-agent", + "version": "0.1.0", + "description": "Mediation Agent Durable Object — per-repo Knowing-State Prosthesis. SPEC-MEDIATION-AGENT-DO-001 v2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@factory/knowing-state-sdk": "workspace:*", + "@factory/schemas": "workspace:*", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@cloudflare/workers-types": "^4.0.0" + } +} diff --git a/packages/mediation-agent/src/index.ts b/packages/mediation-agent/src/index.ts new file mode 100644 index 00000000..7307be5f --- /dev/null +++ b/packages/mediation-agent/src/index.ts @@ -0,0 +1,3 @@ +export { MediationAgentDO } from './mediation-agent-do.js' +export type { Env } from './mediation-agent-do.js' +export type { RepoState, DOEvent, AgentLifecycleState, DetectorSpec, DetectorResult, Verdict } from './types.js' diff --git a/packages/mediation-agent/src/mediation-agent-do.ts b/packages/mediation-agent/src/mediation-agent-do.ts new file mode 100644 index 00000000..81d19a09 --- /dev/null +++ b/packages/mediation-agent/src/mediation-agent-do.ts @@ -0,0 +1,436 @@ +// Mediation Agent Durable Object +// SPEC-MEDIATION-AGENT-DO-001 v2.0 +// One instance per repo — do-key: mediation-agent:{repoId} + +import type { + DOEvent, + RepoState, + LogMeta, + CommissionPayload, + TracePayload, + DivergencePayload, + LifecyclePayload, + VerdictPayload, + FlushPayload, + SnapshotPayload, + AgentLifecycleState, + DetectorResult, +} from './types.js' + +// Env bindings — see SPEC §10 +export type Env = { + ARANGO_URL: string + ARANGO_DB: string + ARANGO_TOKEN: string + COMMISSIONING_AGENT_URL: string + SANDBOX_OUTPUT_BUCKET: R2Bucket + STALENESS_THRESHOLD_HOURS: string + SNAPSHOT_INTERVAL_EVENTS: string + FLUSH_INTERVAL_MS: string + FLUSH_BUFFER_THRESHOLD: string +} + +const SHARD_SIZE = 100 +const DEFAULT_STALENESS_HOURS = 24 +const DEFAULT_SNAPSHOT_INTERVAL = 500 +const DEFAULT_FLUSH_THRESHOLD = 50 + +export class MediationAgentDO implements DurableObject { + constructor( + private readonly state: DurableObjectState, + private readonly env: Env + ) {} + + async fetch(request: Request): Promise { + const url = new URL(request.url) + const method = request.method + + if (method === 'POST' && url.pathname === '/commission') return this.handleCommission(request) + if (method === 'POST' && url.pathname === '/dispatch') return this.handleDispatch(request) + if (method === 'POST' && url.pathname === '/trace') return this.handleTrace(request) + if (method === 'GET' && url.pathname === '/state') return this.handleState() + if (method === 'POST' && url.pathname === '/suspend') return this.handleSuspend(request) + if (method === 'POST' && url.pathname === '/resume') return this.handleResume(request) + if (method === 'POST' && url.pathname === '/close-divergence') return this.handleCloseDivergence(request) + + return new Response('Not found', { status: 404 }) + } + + async alarm(): Promise { + const repoState = await this.reconstructState() + + // Staleness check — I3 enforcement + const staleness = Number(this.env.STALENESS_THRESHOLD_HOURS ?? DEFAULT_STALENESS_HOURS) + const lastCommission = new Date(repoState.lastCommissionAt).getTime() + const staleAt = lastCommission + staleness * 60 * 60 * 1000 + + if (Date.now() > staleAt && repoState.lifecycleState === 'ACTIVE') { + await this.appendEvent({ + type: 'LifecycleEvent', + payload: { + state: 'DEGRADED', + reason: 'staleness-alarm', + triggeredBy: 'alarm', + } satisfies LifecyclePayload, + }) + } + + // Trace flush + await this.flushToArango(repoState) + + // Re-arm alarm + await this.state.storage.setAlarm( + Date.now() + Number(this.env.FLUSH_INTERVAL_MS ?? 60_000) + ) + } + + // ── Handlers ───────────────────────────────────────────────────────────── + + private async handleCommission(request: Request): Promise { + const body = await request.json() as { + workGraphId: string + workGraphVersion: string + arangoLineageRefs: string[] + stalenessThresholdHours?: number + } + + // TODO: fetch WorkGraph from ArangoDB using body.arangoLineageRefs + // TODO: compile AtomDirective[] (SPEC-CONDUCTING-AGENT-001 §1.4) + // TODO: run Coherence Verification + + // Stub: emit LifecycleEvent + CommissionEvent + await this.appendEvent({ + type: 'VerdictEvent', + payload: { + verdictType: 'coherence', + value: 'favorable', + workGraphVersion: body.workGraphVersion, + } satisfies VerdictPayload, + }) + + await this.appendEvent({ + type: 'CommissionEvent' as const, + payload: { + workGraphId: body.workGraphId, + workGraphVersion: body.workGraphVersion, + policyBeadId: `BEAD-ARCH-${body.workGraphVersion}`, + atoms: [], // populated after compile step + detectorSpecs: [], // populated after compile step + agentsMd: '', // populated after compile step + sourceRefs: body.arangoLineageRefs, + committedBy: 'commissioning-agent', + stalenessThresholdHours: body.stalenessThresholdHours ?? DEFAULT_STALENESS_HOURS, + } satisfies CommissionPayload, + }) + + await this.appendEvent({ + type: 'LifecycleEvent', + payload: { + state: 'ACTIVE', + reason: 'commission-success', + triggeredBy: 'commission', + } satisfies LifecyclePayload, + }) + + // Arm staleness alarm + const threshold = (body.stalenessThresholdHours ?? DEFAULT_STALENESS_HOURS) * 60 * 60 * 1000 + await this.state.storage.setAlarm(Date.now() + threshold) + + return Response.json({ + status: 'commissioned', + policyBeadId: `BEAD-ARCH-${body.workGraphVersion}`, + workGraphVersion: body.workGraphVersion, + }) + } + + private async handleDispatch(request: Request): Promise { + const repoState = await this.reconstructState() + + // I2 — Retrieval enforcement + I4 — Fail-closed + if (repoState.lifecycleState !== 'ACTIVE') { + return Response.json({ status: 'blocked', lifecycleState: repoState.lifecycleState }) + } + if (repoState.coherenceVerdict.value !== 'favorable') { + return Response.json({ status: 'blocked', reason: 'coherence-unfavorable' }) + } + + const body = await request.json() as { executionId: string; sessionId: string; requestedAtomRef?: string } + + // TODO: write EngineerRoleBead for this session to ArangoDB + // TODO: DAG-aware atom selection from repoState.atoms + + const atom = repoState.atoms.find(a => !body.requestedAtomRef || a.atomRef === body.requestedAtomRef) + ?? repoState.atoms[0] + + if (!atom) { + return Response.json({ status: 'complete' }) + } + + return Response.json({ + status: 'dispatched', + atom, + policyBeadId: repoState.policyBeadId, + }) + } + + private async handleTrace(request: Request): Promise { + const body = await request.json() as TracePayload + + await this.appendEvent({ type: 'TraceEvent', payload: body }) + + // Run detectors synchronously + const repoState = await this.reconstructState() + const divergences: DivergencePayload[] = [] + + for (const spec of repoState.detectorSpecs) { + let fired = false + if (spec.pattern && new RegExp(spec.pattern).test(body.rawOutput)) fired = true + if (spec.exitCode !== undefined) { + const dr = body.detectorResults.find(r => r.detectorId === spec.detectorId) + if (dr?.fired) fired = true + } + + if (fired) { + const severity = spec.severity === 'critical' ? 'blocking' + : spec.severity === 'warning' ? 'advisory' + : 'informational' + + const div: DivergencePayload = { + divergenceId: `DIV-${body.atomRef}-${Date.now()}`, + atomRef: body.atomRef, + detectorId: spec.detectorId, + severity, + evidence: String(await this.currentSeq()), + } + + divergences.push(div) + await this.appendEvent({ type: 'DivergenceEvent', payload: div }) + + if (severity === 'blocking') { + await this.appendEvent({ + type: 'VerdictEvent', + payload: { + verdictType: 'fidelity', + value: 'unfavorable', + reason: `Blocking divergence: ${spec.detectorId}`, + workGraphVersion: repoState.workGraphVersion, + } satisfies VerdictPayload, + }) + } + } + } + + // Auto-flush check + const threshold = Number(this.env.FLUSH_BUFFER_THRESHOLD ?? DEFAULT_FLUSH_THRESHOLD) + const pendingCount = await this.pendingTraceCount() + if (pendingCount >= threshold) await this.flushToArango(repoState) + + return Response.json({ + status: 'recorded', + divergencesDetected: divergences.length, + blockingCount: divergences.filter(d => d.severity === 'blocking').length, + }) + } + + private async handleState(): Promise { + const s = await this.reconstructState() + const meta = await this.logMeta() + + return Response.json({ + lifecycleState: s.lifecycleState, + workGraphVersion: s.workGraphVersion, + policyBeadId: s.policyBeadId, + coherenceVerdict: s.coherenceVerdict, + fidelityVerdict: s.fidelityVerdict, + openDivergences: { + // TODO: classify from s.openDivergenceIds + blocking: 0, + advisory: 0, + informational: 0, + }, + eventLogDepth: meta.totalEvents, + lastFlushAt: '', // TODO: derive from FlushEvent + }) + } + + private async handleSuspend(request: Request): Promise { + await this.appendEvent({ + type: 'LifecycleEvent', + payload: { state: 'SUSPENDED', reason: 'explicit-suspend', triggeredBy: 'suspend' } satisfies LifecyclePayload, + }) + return Response.json({ status: 'suspended' }) + } + + private async handleResume(request: Request): Promise { + // Resume requires re-commission — handled by Commissioning Agent + return Response.json({ status: 'awaiting-commission' }) + } + + private async handleCloseDivergence(request: Request): Promise { + const body = await request.json() as { divergenceId: string; closedBy: string } + await this.appendEvent({ + type: 'DivergenceClosedEvent', + payload: { divergenceId: body.divergenceId, closedBy: body.closedBy, closedAt: new Date().toISOString() }, + }) + return Response.json({ status: 'closed' }) + } + + // ── Event log ───────────────────────────────────────────────────────────── + + private async appendEvent(event: Omit): Promise { + await this.state.storage.transaction(async txn => { + const meta = (await txn.get('log:meta')) ?? { currentShard: 0, totalEvents: 0, lastSnapshotSeq: 0 } + const seq = meta.totalEvents + const full: DOEvent = { seq, producedAt: new Date().toISOString(), ...event } + + const shardKey = `log:shard:${meta.currentShard}` + const shard = (await txn.get(shardKey)) ?? [] + shard.push(full) + + const newShard = shard.length >= SHARD_SIZE ? meta.currentShard + 1 : meta.currentShard + await txn.put(shardKey, shard) + await txn.put('log:meta', { ...meta, totalEvents: seq + 1, currentShard: newShard }) + }) + + // Write snapshot periodically + const meta = await this.logMeta() + const snapshotInterval = Number(this.env.SNAPSHOT_INTERVAL_EVENTS ?? DEFAULT_SNAPSHOT_INTERVAL) + if (meta.totalEvents % snapshotInterval === 0) await this.writeSnapshot() + } + + private async reconstructState(): Promise { + const meta = await this.logMeta() + + // Check for recent snapshot first + const snapshot = await this.state.storage.get('snapshot:latest') + + // Read last 200 events (configurable) + const events = await this.readRecentEvents(200) + + // Start from snapshot if available and recent + let state: RepoState = snapshot + ? this.stateFromSnapshot(snapshot) + : this.emptyState() + + // Apply events after snapshot seq + for (const event of events) { + if (snapshot && event.seq <= snapshot.atSeq) continue + state = this.applyEvent(state, event) + } + + return state + } + + private applyEvent(state: RepoState, event: DOEvent): RepoState { + switch (event.type) { + case 'CommissionEvent': { + const p = event.payload as CommissionPayload + return { ...state, workGraphVersion: p.workGraphVersion, policyBeadId: p.policyBeadId, atoms: p.atoms, detectorSpecs: p.detectorSpecs, agentsMd: p.agentsMd, lastCommissionAt: event.producedAt, stalenessThresholdHours: p.stalenessThresholdHours } + } + case 'LifecycleEvent': { + const p = event.payload as LifecyclePayload + return { ...state, lifecycleState: p.state } + } + case 'VerdictEvent': { + const p = event.payload as VerdictPayload + const verdict = p.reason !== undefined ? { value: p.value, reason: p.reason } : { value: p.value } + if (p.verdictType === 'coherence') return { ...state, coherenceVerdict: verdict } + return { ...state, fidelityVerdict: verdict } + } + case 'DivergenceEvent': { + const p = event.payload as DivergencePayload + return { ...state, openDivergenceIds: [...state.openDivergenceIds, p.divergenceId] } + } + case 'DivergenceClosedEvent': { + const p = event.payload as { divergenceId: string } + return { ...state, openDivergenceIds: state.openDivergenceIds.filter(id => id !== p.divergenceId) } + } + case 'FlushEvent': { + const p = event.payload as FlushPayload + return { ...state, lastFlushSeq: p.flushedSeqRange[1] } + } + default: return state + } + } + + private emptyState(): RepoState { + return { + lifecycleState: 'UNINITIALIZED', + workGraphVersion: '', + policyBeadId: '', + atoms: [], + detectorSpecs: [], + agentsMd: '', + coherenceVerdict: { value: 'pending' }, + fidelityVerdict: { value: 'pending' }, + openDivergenceIds: [], + lastCommissionAt: new Date(0).toISOString(), + stalenessThresholdHours: DEFAULT_STALENESS_HOURS, + lastFlushSeq: -1, + } + } + + private stateFromSnapshot(s: SnapshotPayload): RepoState { + return { + ...this.emptyState(), + lifecycleState: s.lifecycleState as AgentLifecycleState, + workGraphVersion: s.workGraphVersion, + openDivergenceIds: s.openDivergenceIds, + coherenceVerdict: { value: s.coherenceVerdict as 'favorable' | 'unfavorable' | 'pending' }, + fidelityVerdict: { value: s.fidelityVerdict as 'favorable' | 'unfavorable' | 'pending' }, + } + } + + private async writeSnapshot(): Promise { + const state = await this.reconstructState() + const meta = await this.logMeta() + const snapshot: SnapshotPayload = { + atSeq: meta.totalEvents - 1, + lifecycleState: state.lifecycleState, + workGraphVersion: state.workGraphVersion, + openDivergenceIds: state.openDivergenceIds, + coherenceVerdict: state.coherenceVerdict.value, + fidelityVerdict: state.fidelityVerdict.value, + } + await this.state.storage.put('snapshot:latest', snapshot) + await this.appendEvent({ type: 'SnapshotEvent', payload: snapshot }) + } + + private async readRecentEvents(n: number): Promise { + const meta = await this.logMeta() + const events: DOEvent[] = [] + let shard = meta.currentShard + + while (events.length < n && shard >= 0) { + const chunk = (await this.state.storage.get(`log:shard:${shard}`)) ?? [] + events.unshift(...chunk) + shard-- + } + + return events.slice(-n) + } + + private async logMeta(): Promise { + return (await this.state.storage.get('log:meta')) ?? { currentShard: 0, totalEvents: 0, lastSnapshotSeq: 0 } + } + + private async currentSeq(): Promise { + const meta = await this.logMeta() + return meta.totalEvents - 1 + } + + private async pendingTraceCount(): Promise { + const state = await this.reconstructState() + const meta = await this.logMeta() + return meta.totalEvents - state.lastFlushSeq - 1 + } + + // ── ArangoDB flush ──────────────────────────────────────────────────────── + + private async flushToArango(_state: RepoState): Promise { + // TODO: implement Bead flush (SPEC-MEDIATION-AGENT-DO-001 §5.2) + // CommitBead + BuildOutcomeBead + PatternTrustBead + AuditBead per pending TraceEvent + // Single ArangoDB multi-document transaction + // Append FlushEvent on success + } +} diff --git a/packages/mediation-agent/src/types.ts b/packages/mediation-agent/src/types.ts new file mode 100644 index 00000000..ea4edb7e --- /dev/null +++ b/packages/mediation-agent/src/types.ts @@ -0,0 +1,137 @@ +// Mediation Agent DO — event log types +// SPEC-MEDIATION-AGENT-DO-001 v2.0 §2.1 + +import type { AtomDirective } from '@factory/schemas' + +export type AgentLifecycleState = + | 'UNINITIALIZED' + | 'ACTIVE' + | 'SUSPENDED' + | 'DEGRADED' + | 'TERMINATED' + +export type DetectorSpec = { + detectorId: string // INV-* ref + pattern?: string // regex; type: 'pattern' + exitCode?: number // type: 'exit-code' + severity: 'critical' | 'warning' | 'info' +} + +export type DetectorResult = { + detectorId: string + fired: boolean + severity?: 'critical' | 'warning' | 'info' + matchedContent?: string +} + +export type Verdict = { + value: 'favorable' | 'unfavorable' | 'pending' + reason?: string +} + +// ── Event types ─────────────────────────────────────────────────────────── + +export type EventType = + | 'CommissionEvent' + | 'TraceEvent' + | 'DivergenceEvent' + | 'DivergenceClosedEvent' + | 'VerdictEvent' + | 'LifecycleEvent' + | 'FlushEvent' + | 'SnapshotEvent' + +export type DOEvent = { + seq: number + type: EventType + producedAt: string + payload: unknown +} + +export type CommissionPayload = { + workGraphId: string + workGraphVersion: string + policyBeadId: string + atoms: AtomDirective[] + detectorSpecs: DetectorSpec[] + agentsMd: string + sourceRefs: string[] + committedBy: string + stalenessThresholdHours: number +} + +export type TracePayload = { + executionId: string + directiveId: string + atomRef: string + outcome: 'success' | 'failure' | 'timeout' | 'cancelled' + rawOutput: string // truncated to 4KB + sandboxOutputRef?: string // presigned R2 URL + durationMs: number + attemptNumber: number + detectorResults: DetectorResult[] +} + +export type DivergencePayload = { + divergenceId: string // DIV-* ID + atomRef: string + detectorId: string + severity: 'blocking' | 'advisory' | 'informational' + evidence: string // TraceEvent seq ref +} + +export type DivergenceClosedPayload = { + divergenceId: string + closedBy: string + closedAt: string +} + +export type VerdictPayload = { + verdictType: 'coherence' | 'fidelity' + value: 'favorable' | 'unfavorable' | 'pending' + reason?: string + workGraphVersion: string +} + +export type LifecyclePayload = { + state: AgentLifecycleState + reason: string + triggeredBy: 'commission' | 'suspend' | 'resume' | 'alarm' | 'terminate' +} + +export type FlushPayload = { + flushedSeqRange: [number, number] + beadIdsWritten: string[] +} + +export type SnapshotPayload = { + atSeq: number + lifecycleState: AgentLifecycleState + workGraphVersion: string + openDivergenceIds: string[] + coherenceVerdict: string + fidelityVerdict: string +} + +// ── Reconstructed state (derived from event log tail) ───────────────────── + +export type RepoState = { + lifecycleState: AgentLifecycleState + workGraphVersion: string + policyBeadId: string + atoms: AtomDirective[] + detectorSpecs: DetectorSpec[] + agentsMd: string + coherenceVerdict: Verdict + fidelityVerdict: Verdict + openDivergenceIds: string[] + lastCommissionAt: string + stalenessThresholdHours: number + lastFlushSeq: number +} + +export type LogMeta = { + currentShard: number + totalEvents: number + lastSnapshotSeq: number +} diff --git a/packages/mediation-agent/tsconfig.json b/packages/mediation-agent/tsconfig.json new file mode 100644 index 00000000..911bc16c --- /dev/null +++ b/packages/mediation-agent/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/packages/schemas/src/atom-directive.ts b/packages/schemas/src/atom-directive.ts new file mode 100644 index 00000000..5d0b16ee --- /dev/null +++ b/packages/schemas/src/atom-directive.ts @@ -0,0 +1,81 @@ +// AtomDirective — canonical schema for the I-layer / execution-substrate boundary +// SPEC-CONDUCTING-AGENT-001 §1.2 — authoritative definition +// Referenced by: SPEC-MEDIATION-AGENT-DO-001, SPEC-COMMISSIONING-AGENT-001, +// SPEC-ARCHITECT-AGENT-DO-001 + +import { z } from 'zod' + +export const ToolPermission = z.enum([ + 'shell', // shell.schema.json tool set + 'git', // git.schema.json tool set + 'compiler', // compiler.schema.json tool set + 'read-only', // subset of shell: cat, ls, find, grep, head, tail, wc only +]) + +// No z.default() inside SuccessCondition variants — keeps input/output types identical +// which allows the recursive ZodType constraint to be satisfied. +export type SuccessConditionType = + | { type: 'exit-code'; expectedCode: number } + | { type: 'output-contains'; substring: string } + | { type: 'output-matches'; pattern: string } + | { type: 'file-exists'; path: string } + | { type: 'composite'; all: SuccessConditionType[] } // max depth 3 + +export const SuccessCondition: z.ZodType = z.lazy(() => + z.union([ + z.object({ type: z.literal('exit-code'), expectedCode: z.number().int() }), + z.object({ type: z.literal('output-contains'), substring: z.string().min(1) }), + z.object({ type: z.literal('output-matches'), pattern: z.string().min(1) }), + z.object({ type: z.literal('file-exists'), path: z.string().min(1) }), + z.object({ type: z.literal('composite'), all: z.array(SuccessCondition).min(2).max(8) }), + ]) +) + +export const RetryPolicy = z.object({ + maxAttempts: z.number().int().min(1).max(5).default(3), + backoffMs: z.number().int().min(0).default(1000), + isolatedRetry: z.boolean().default(true), +}) + +export const AtomDirective = z.object({ + // ── Identity ──────────────────────────────────────────────────────────── + directiveId: z.string().regex(/^DIR-[A-Z0-9]+-\d+$/), + atomRef: z.string().min(1), + workGraphVersion: z.string().min(1), + repoId: z.string().min(1), + + // ── Execution instruction ──────────────────────────────────────────────── + // Must be procedural (sequence of steps), never conceptual. + instruction: z.string().min(10).max(2000), + workingDir: z.string().default('.'), + + // ── Tool permissions ───────────────────────────────────────────────────── + // Explicit allowlist. Any tool call outside this set is rejected. + permittedTools: z.array(ToolPermission).min(1), + + // ── Timing ────────────────────────────────────────────────────────────── + timeoutMs: z.number().int().min(1000).max(300000).default(60000), + + // ── Success definition ─────────────────────────────────────────────────── + successCondition: SuccessCondition, + + // ── Retry policy ──────────────────────────────────────────────────────── + retryPolicy: RetryPolicy, + + // ── DAG ordering ──────────────────────────────────────────────────────── + // directiveIds that must complete successfully before this one starts. + dependsOn: z.array(z.string()).default([]), + + // ── Context injection ──────────────────────────────────────────────────── + envVars: z.record(z.string(), z.string()).default({}), + + // ── Sandbox configuration ──────────────────────────────────────────────── + sandboxConfig: z.object({ + memoryMb: z.number().int().min(128).max(4096).default(512), + persistFilesystem: z.boolean().default(false), + }).default({}), +}) + +export type AtomDirective = z.infer +export type RetryPolicy = z.infer +export type ToolPermission = z.infer diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index bf154967..bd25d52d 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -19,3 +19,4 @@ export * from "./trust.js" export * from "./decision-state.js" export * from "./commit-triage.js" export * from "./sdlc.js" +export * from "./atom-directive.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8d03485..8a7923bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,25 @@ importers: specifier: ^1.4.0 version: 1.6.1(@types/node@24.12.2) + packages/architect-agent: + dependencies: + '@factory/knowing-state-sdk': + specifier: workspace:* + version: link:../knowing-state-sdk + '@factory/schemas': + specifier: workspace:* + version: link:../schemas + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.0.0 + version: 4.20260425.1 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/architecture-candidates: dependencies: '@factory/schemas': @@ -128,6 +147,25 @@ importers: specifier: ^1.4.0 version: 1.6.1(@types/node@24.12.2) + packages/commissioning-agent: + dependencies: + '@factory/knowing-state-sdk': + specifier: workspace:* + version: link:../knowing-state-sdk + '@factory/schemas': + specifier: workspace:* + version: link:../schemas + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.0.0 + version: 4.20260425.1 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/compiler: dependencies: '@factory/coverage-gates': @@ -156,6 +194,25 @@ importers: specifier: ^1.4.0 version: 1.6.1(@types/node@20.19.39) + packages/conducting-agent: + dependencies: + '@factory/knowing-state-sdk': + specifier: workspace:* + version: link:../knowing-state-sdk + '@factory/schemas': + specifier: workspace:* + version: link:../schemas + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.0.0 + version: 4.20260425.1 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/controlled-effectors: dependencies: '@factory/schemas': @@ -354,6 +411,16 @@ importers: specifier: ^5 version: 5.9.3 + packages/knowing-state-sdk: + dependencies: + '@factory/schemas': + specifier: workspace:* + version: link:../schemas + devDependencies: + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/literate-tools: dependencies: '@factory/schemas': @@ -370,6 +437,25 @@ importers: specifier: ^1.6.1 version: 1.6.1(@types/node@24.12.2) + packages/mediation-agent: + dependencies: + '@factory/knowing-state-sdk': + specifier: workspace:* + version: link:../knowing-state-sdk + '@factory/schemas': + specifier: workspace:* + version: link:../schemas + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.0.0 + version: 4.20260425.1 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + packages/meta-governance: dependencies: '@factory/schemas': @@ -618,7 +704,7 @@ importers: version: link:../../packages/gdk-ai agents: specifier: 0.11.6 - version: 0.11.6(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.3.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(ai@6.0.168(zod@3.25.76))(zod@3.25.76))(@cloudflare/workers-types@4.20260425.1)(ai@6.0.168(zod@3.25.76))(react@19.2.5)(rolldown@1.0.0-rc.18)(vite@5.4.21(@types/node@24.12.2))(zod@3.25.76) + version: 0.11.6(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.3.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(ai@6.0.168(zod@3.25.76))(zod@3.25.76))(@cloudflare/workers-types@4.20260425.1)(ai@6.0.168(zod@3.25.76))(react@19.2.5)(rolldown@1.0.0-rc.18)(zod@3.25.76) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260101.0 @@ -5015,14 +5101,13 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.18)(vite@5.4.21(@types/node@24.12.2))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.18)': dependencies: '@babel/core': 7.29.0 picomatch: 4.0.4 rolldown: 1.0.0-rc.18 optionalDependencies: '@babel/runtime': 7.29.2 - vite: 5.4.21(@types/node@24.12.2) '@rolldown/pluginutils@1.0.0-rc.18': {} @@ -5573,12 +5658,12 @@ snapshots: agent-base@7.1.4: {} - agents@0.11.6(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.3.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(ai@6.0.168(zod@3.25.76))(zod@3.25.76))(@cloudflare/workers-types@4.20260425.1)(ai@6.0.168(zod@3.25.76))(react@19.2.5)(rolldown@1.0.0-rc.18)(vite@5.4.21(@types/node@24.12.2))(zod@3.25.76): + agents@0.11.6(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.3.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(ai@6.0.168(zod@3.25.76))(zod@3.25.76))(@cloudflare/workers-types@4.20260425.1)(ai@6.0.168(zod@3.25.76))(react@19.2.5)(rolldown@1.0.0-rc.18)(zod@3.25.76): dependencies: '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) '@cfworker/json-schema': 4.1.1 '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.18)(vite@5.4.21(@types/node@24.12.2)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0-rc.18) ai: 6.0.168(zod@3.25.76) cron-schedule: 6.0.0 mimetext: 3.0.28 @@ -5590,7 +5675,6 @@ snapshots: zod: 3.25.76 optionalDependencies: '@cloudflare/codemode': 0.3.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76))(ai@6.0.168(zod@3.25.76))(zod@3.25.76) - vite: 5.4.21(@types/node@24.12.2) transitivePeerDependencies: - '@babel/core' - '@babel/plugin-transform-runtime'