@thebackstoryis/engineering-with-ai 0.2.9
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/Docs/README.md +50 -0
- package/Docs/adoption/consultancy-and-multi-project-rollout.md +135 -0
- package/Docs/adoption/non-technical-team-guide.md +126 -0
- package/Docs/archaeology-technology-and-hosting-discovery.md +212 -0
- package/Docs/blast-radius-and-impact-routing-guide.md +325 -0
- package/Docs/blueprints/internal-blueprint-catalogue.md +146 -0
- package/Docs/blueprints/maintaining-organisation-blueprints.md +154 -0
- package/Docs/blueprints/validation-and-troubleshooting.md +168 -0
- package/Docs/cli-reference.md +113 -0
- package/Docs/completed-phase-evidence-amendments.md +74 -0
- package/Docs/consultancy-network-rollout-control-plane-guide.md +202 -0
- package/Docs/context-aware-delivery-companion-guide.md +198 -0
- package/Docs/context-aware-delivery-companion-user-guide.md +184 -0
- package/Docs/context-management-and-token-efficiency.md +113 -0
- package/Docs/design-systems/design-system-implementation-guide.md +85 -0
- package/Docs/design-systems/design-system-pack-authoring-guide.md +95 -0
- package/Docs/design-systems/design-system-review-guide.md +51 -0
- package/Docs/design-systems/design-system-user-guide.md +96 -0
- package/Docs/design-systems/product-owner-guide.md +49 -0
- package/Docs/designing-organisation-blueprint-packs.md +384 -0
- package/Docs/developer-delivery-guide.md +224 -0
- package/Docs/error-reporting-guide.md +110 -0
- package/Docs/error-reporting-provider-guide.md +49 -0
- package/Docs/examples/error-report-adapter.md +70 -0
- package/Docs/examples/meeting-review.md +76 -0
- package/Docs/examples/minimal-design-system.md +67 -0
- package/Docs/examples/prototype-review-inputs.md +175 -0
- package/Docs/examples/reproducible-archaeology-depth-example.md +144 -0
- package/Docs/examples/test-scenario-input.md +68 -0
- package/Docs/examples/worked-examples.md +147 -0
- package/Docs/existing-project-onboarding-guide.md +214 -0
- package/Docs/explanation/core-concepts.md +26 -0
- package/Docs/explanation/delivery-workflow.md +48 -0
- package/Docs/governance/governance-team-guide.md +139 -0
- package/Docs/governed-starter-project-materialisation-guide.md +284 -0
- package/Docs/guide-catalogue.md +117 -0
- package/Docs/guided-discovery-facilitator-guide.md +172 -0
- package/Docs/guided-intent-workspace-guide.md +109 -0
- package/Docs/guided-phase-evidence-drafting-guide.md +119 -0
- package/Docs/human-approval-and-assurance-guide.md +146 -0
- package/Docs/knowledge-proposals-implementer-guide.md +106 -0
- package/Docs/knowledge-proposals-user-guide.md +247 -0
- package/Docs/maintainers/context-benchmarks.md +29 -0
- package/Docs/maintainers/contributing.md +58 -0
- package/Docs/maintainers/evidence-depth-acceptance.md +72 -0
- package/Docs/maintainers/verification-walkthroughs.md +104 -0
- package/Docs/meeting-evidence-implementer-guide.md +132 -0
- package/Docs/meeting-evidence-user-guide.md +200 -0
- package/Docs/operations/dashboard-and-delivery-state.md +153 -0
- package/Docs/operations/dashboard-configuration.md +87 -0
- package/Docs/operations/installation-updating-and-entitlements.md +135 -0
- package/Docs/operations/premium-personas-setup.md +76 -0
- package/Docs/operations/troubleshooting-and-recovery.md +205 -0
- package/Docs/organisation-rollout-guide.md +142 -0
- package/Docs/persona-entitlement-provider-guide.md +199 -0
- package/Docs/persona-guided-prototype-iteration.md +129 -0
- package/Docs/personas/organisation-specific-personas.md +103 -0
- package/Docs/personas/persona-authoring-cookbook.md +176 -0
- package/Docs/personas/persona-engagement-ui.md +133 -0
- package/Docs/personas/persona-governance.md +118 -0
- package/Docs/platform-export-analysis-guide.md +336 -0
- package/Docs/policies/governance-owner-guide.md +36 -0
- package/Docs/policies/implementation-guide.md +42 -0
- package/Docs/policies/organisation-policy-design-gates.md +58 -0
- package/Docs/policies/policy-pack-authoring-guide.md +108 -0
- package/Docs/policies/product-owner-guide.md +43 -0
- package/Docs/policies/technical-owner-guide.md +37 -0
- package/Docs/product-owner-guide.md +327 -0
- package/Docs/project-portfolio-orchestration-guide.md +199 -0
- package/Docs/quality/manual-qa-and-acceptance.md +162 -0
- package/Docs/quality/persona-driven-test-scenarios.md +172 -0
- package/Docs/quality/reproducible-archaeology-depth-review-checklist.md +89 -0
- package/Docs/reference/capabilities-and-project-layout.md +678 -0
- package/Docs/reference/cli-and-configuration.md +398 -0
- package/Docs/reference/contributions-api.md +23 -0
- package/Docs/reference/security-adapter-authoring.md +81 -0
- package/Docs/reference/starter-adapter-authoring.md +74 -0
- package/Docs/repository-source-map-guide.md +381 -0
- package/Docs/reproducible-archaeology-and-discovery-depth.md +292 -0
- package/Docs/screen-prototype-creation-guide.md +324 -0
- package/Docs/security-validation-guide.md +353 -0
- package/Docs/solution-readiness-review-guide.md +123 -0
- package/Docs/standards/project-standards-authoring.md +157 -0
- package/Docs/team-hub-guide.md +162 -0
- package/Docs/team-hub-resource-registry-guide.md +167 -0
- package/Docs/tutorials/first-delivery.md +83 -0
- package/Docs/tutorials/first-session.md +62 -0
- package/Docs/using-lifecycle-hooks.md +381 -0
- package/Docs/working-with-personas.md +274 -0
- package/LICENSE +165 -0
- package/README.md +96 -0
- package/agents-src/claude/ewai-security-reviewer.md +15 -0
- package/bin/ewai +5 -0
- package/config/archaeology-record-families.yaml +59 -0
- package/config/delivery-artifacts.yaml +121 -0
- package/config/delivery-stages.yaml +77 -0
- package/config/design-system.schema.json +46 -0
- package/config/error-reporting.schema.json +79 -0
- package/config/evidence-depth.schema.json +53 -0
- package/config/intent.schema.json +90 -0
- package/config/knowledge-proposals-proposal.schema.json +34 -0
- package/config/lifecycle-event.schema.json +68 -0
- package/config/lifecycle-handler.schema.json +45 -0
- package/config/lifecycle-hook-ack.schema.json +19 -0
- package/config/meeting-evidence-candidate.schema.json +102 -0
- package/config/organisation-policy.schema.json +137 -0
- package/config/pack.schema.json +250 -0
- package/config/persona-pack.schema.json +21 -0
- package/config/persona.schema.json +17 -0
- package/config/policy-evaluation.schema.json +77 -0
- package/config/policy-facts.schema.json +140 -0
- package/config/portfolio.schema.json +68 -0
- package/config/project.schema.json +313 -0
- package/config/prototype-iteration.schema.json +128 -0
- package/config/rollout.schema.json +87 -0
- package/config/security-adapter.schema.json +31 -0
- package/config/security-scan-request.schema.json +64 -0
- package/config/security-scan-response.schema.json +52 -0
- package/config/security-validation-policy.schema.json +74 -0
- package/config/starter-source-acknowledgement.schema.json +13 -0
- package/config/starter-source-adapter.schema.json +38 -0
- package/config/starter-source-request.schema.json +61 -0
- package/package.json +77 -0
- package/packs/core/pack.yaml +7 -0
- package/packs/design-systems/default/experience-promise.md +9 -0
- package/packs/design-systems/default/intentional-review.md +10 -0
- package/packs/design-systems/default/interaction-and-entry.md +9 -0
- package/packs/design-systems/default/meaningful-content-and-states.md +9 -0
- package/packs/design-systems/default/pack.yaml +44 -0
- package/packs/design-systems/default/principles.md +10 -0
- package/packs/personas/core/pack.yaml +7 -0
- package/packs/personas/core/personas/archaeologist.md +37 -0
- package/packs/personas/core/personas/end-user.md +17 -0
- package/packs/personas/core/personas/maintainer.md +17 -0
- package/packs/personas/core/personas/operator.md +17 -0
- package/packs/personas/core/personas/specs-knowledge-curator.md +35 -0
- package/packs/technologies/laravel/pack.yaml +30 -0
- package/packs/technologies/laravel-nuxt/pack.yaml +30 -0
- package/packs/technologies/nuxt/pack.yaml +30 -0
- package/packs/technologies/power-platform/pack.yaml +31 -0
- package/packs/technologies/salesforce/pack.yaml +25 -0
- package/public/app.js +4896 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/backstory-icon.png +0 -0
- package/public/dashboard-navigation.js +98 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +789 -0
- package/public/styles.css +2693 -0
- package/public/team-hub/app.js +202 -0
- package/public/team-hub/index.html +79 -0
- package/public/team-hub/styles.css +90 -0
- package/scripts/publication-check.mjs +140 -0
- package/scripts/setup.mjs +21 -0
- package/skills-src/ewai-archaeology/SKILL.md +334 -0
- package/skills-src/ewai-archaeology/agents/openai.yaml +4 -0
- package/skills-src/ewai-archaeology/references/archaeology-contract.md +201 -0
- package/skills-src/ewai-archaeology/references/lifecycle-reconstruction.md +177 -0
- package/skills-src/ewai-archaeology/references/maximum-detail-reconstruction.md +97 -0
- package/skills-src/ewai-archaeology/references/model-routing.md +26 -0
- package/skills-src/ewai-architecture/SKILL.md +108 -0
- package/skills-src/ewai-architecture/agents/openai.yaml +4 -0
- package/skills-src/ewai-architecture/references/architecture-contract.md +176 -0
- package/skills-src/ewai-context/SKILL.md +68 -0
- package/skills-src/ewai-context/agents/openai.yaml +4 -0
- package/skills-src/ewai-context-import/SKILL.md +118 -0
- package/skills-src/ewai-context-import/agents/openai.yaml +4 -0
- package/skills-src/ewai-context-import/references/context-import-contract.md +106 -0
- package/skills-src/ewai-dashboard-configuration/SKILL.md +20 -0
- package/skills-src/ewai-deliver/SKILL.md +122 -0
- package/skills-src/ewai-deliver/references/delivery-evidence.md +92 -0
- package/skills-src/ewai-deliver/references/phase-routing.md +31 -0
- package/skills-src/ewai-design-system-apply/SKILL.md +27 -0
- package/skills-src/ewai-design-system-apply/agents/openai.yaml +4 -0
- package/skills-src/ewai-design-system-apply/references/application-contract.md +36 -0
- package/skills-src/ewai-design-system-author/SKILL.md +28 -0
- package/skills-src/ewai-design-system-author/agents/openai.yaml +4 -0
- package/skills-src/ewai-design-system-author/references/authoring-contract.md +38 -0
- package/skills-src/ewai-design-system-review/SKILL.md +26 -0
- package/skills-src/ewai-design-system-review/agents/openai.yaml +4 -0
- package/skills-src/ewai-design-system-review/references/review-contract.md +40 -0
- package/skills-src/ewai-error-reporting/SKILL.md +46 -0
- package/skills-src/ewai-error-reporting/agents/openai.yaml +4 -0
- package/skills-src/ewai-error-reporting/references/provider-contract.md +74 -0
- package/skills-src/ewai-evidence-depth/SKILL.md +72 -0
- package/skills-src/ewai-evidence-depth/agents/openai.yaml +4 -0
- package/skills-src/ewai-evidence-depth/references/evidence-depth-contract.md +127 -0
- package/skills-src/ewai-intent/SKILL.md +68 -0
- package/skills-src/ewai-intent/agents/openai.yaml +4 -0
- package/skills-src/ewai-intent/references/intent-contract.md +42 -0
- package/skills-src/ewai-knowledge-proposals/SKILL.md +105 -0
- package/skills-src/ewai-knowledge-proposals/agents/openai.yaml +4 -0
- package/skills-src/ewai-knowledge-proposals/references/proposal-contract.md +59 -0
- package/skills-src/ewai-meeting-evidence/SKILL.md +106 -0
- package/skills-src/ewai-meeting-evidence/agents/openai.yaml +4 -0
- package/skills-src/ewai-meeting-evidence/references/candidate-contract.md +64 -0
- package/skills-src/ewai-organisation-policy/SKILL.md +62 -0
- package/skills-src/ewai-organisation-policy/agents/openai.yaml +4 -0
- package/skills-src/ewai-organisation-policy/references/policy-contract.md +94 -0
- package/skills-src/ewai-palace-housekeeping/SKILL.md +55 -0
- package/skills-src/ewai-palace-housekeeping/agents/openai.yaml +4 -0
- package/skills-src/ewai-persona-entitlement/SKILL.md +60 -0
- package/skills-src/ewai-persona-entitlement/agents/openai.yaml +4 -0
- package/skills-src/ewai-phase-evidence/SKILL.md +79 -0
- package/skills-src/ewai-phase-evidence/agents/openai.yaml +4 -0
- package/skills-src/ewai-pipeline/SKILL.md +130 -0
- package/skills-src/ewai-pipeline/agents/openai.yaml +4 -0
- package/skills-src/ewai-pipeline/references/cli.md +86 -0
- package/skills-src/ewai-pipeline/references/specs-contract.md +16 -0
- package/skills-src/ewai-portfolio/SKILL.md +70 -0
- package/skills-src/ewai-portfolio/agents/openai.yaml +4 -0
- package/skills-src/ewai-portfolio/references/portfolio-contract.md +67 -0
- package/skills-src/ewai-project-discovery/SKILL.md +95 -0
- package/skills-src/ewai-project-discovery/agents/openai.yaml +4 -0
- package/skills-src/ewai-project-discovery/references/discovery-contract.md +34 -0
- package/skills-src/ewai-prototype-iteration/SKILL.md +30 -0
- package/skills-src/ewai-prototype-iteration/agents/openai.yaml +4 -0
- package/skills-src/ewai-prototype-iteration/references/review-contract.md +49 -0
- package/skills-src/ewai-retro/SKILL.md +48 -0
- package/skills-src/ewai-retro/agents/openai.yaml +4 -0
- package/skills-src/ewai-retro/references/asset-routing.md +14 -0
- package/skills-src/ewai-rollout/SKILL.md +74 -0
- package/skills-src/ewai-rollout/agents/openai.yaml +4 -0
- package/skills-src/ewai-rollout/references/rollout-contract.md +74 -0
- package/skills-src/ewai-shape-intents/SKILL.md +84 -0
- package/skills-src/ewai-shape-intents/agents/openai.yaml +4 -0
- package/skills-src/ewai-shape-intents/references/intent-mapping-contract.md +109 -0
- package/skills-src/ewai-solution-readiness/SKILL.md +55 -0
- package/skills-src/ewai-solution-readiness/agents/openai.yaml +4 -0
- package/skills-src/ewai-standards-check/SKILL.md +93 -0
- package/skills-src/ewai-standards-check/agents/openai.yaml +4 -0
- package/skills-src/ewai-standards-check/references/report-contract.md +116 -0
- package/skills-src/ewai-test-scenarios/SKILL.md +94 -0
- package/skills-src/ewai-test-scenarios/agents/openai.yaml +4 -0
- package/skills-src/ewai-test-scenarios/references/scenario-contract.md +88 -0
- package/src/afk-worker.mjs +16 -0
- package/src/archaeology.mjs +1333 -0
- package/src/checkin.mjs +261 -0
- package/src/cli.mjs +2427 -0
- package/src/companion-guidance.mjs +257 -0
- package/src/companion-opening.mjs +62 -0
- package/src/companion.mjs +256 -0
- package/src/context.mjs +210 -0
- package/src/dashboard-preferences.mjs +80 -0
- package/src/delivery-artifacts.mjs +204 -0
- package/src/delivery-documents.mjs +248 -0
- package/src/delivery-gates.mjs +317 -0
- package/src/delivery.mjs +1433 -0
- package/src/design-system-application.mjs +291 -0
- package/src/design-system-authoring.mjs +101 -0
- package/src/design-systems.mjs +466 -0
- package/src/discovery.mjs +1314 -0
- package/src/error-reporting.mjs +323 -0
- package/src/evidence-depth.mjs +543 -0
- package/src/execution-state.mjs +243 -0
- package/src/install.mjs +166 -0
- package/src/intent-dependencies.mjs +117 -0
- package/src/intent-maps.mjs +402 -0
- package/src/intents.mjs +747 -0
- package/src/knowledge-proposals.mjs +717 -0
- package/src/launcher.mjs +51 -0
- package/src/meeting-evidence.mjs +703 -0
- package/src/network-rollout.mjs +386 -0
- package/src/organisation-blueprints.mjs +438 -0
- package/src/organisation-policies.mjs +448 -0
- package/src/packs.mjs +44 -0
- package/src/paths.mjs +62 -0
- package/src/persona-entitlements.mjs +438 -0
- package/src/persona-licence-config.mjs +98 -0
- package/src/persona-website-provider.mjs +134 -0
- package/src/persona-zip.mjs +87 -0
- package/src/personas.mjs +159 -0
- package/src/platform-metadata-analysis.mjs +314 -0
- package/src/policy-design-gates.mjs +623 -0
- package/src/policy-gate-integration.mjs +318 -0
- package/src/portfolio.mjs +509 -0
- package/src/power-platform-source-map.mjs +190 -0
- package/src/project.mjs +449 -0
- package/src/prototype-iterations.mjs +730 -0
- package/src/repository-source-map.mjs +603 -0
- package/src/runtime/afk-conductor.mjs +973 -0
- package/src/runtime/context-assembly.mjs +457 -0
- package/src/runtime/context-benchmarks.mjs +115 -0
- package/src/runtime/dashboard-actions.mjs +109 -0
- package/src/runtime/dashboard-handoffs.mjs +149 -0
- package/src/runtime/dashboard-server.mjs +1272 -0
- package/src/runtime/dashboard.mjs +197 -0
- package/src/runtime/database.mjs +789 -0
- package/src/runtime/error-reporting.mjs +581 -0
- package/src/runtime/evidence-depth-workspace.mjs +412 -0
- package/src/runtime/execution-leases.mjs +299 -0
- package/src/runtime/guided-discovery.mjs +350 -0
- package/src/runtime/guided-intents.mjs +517 -0
- package/src/runtime/impact-analysis.mjs +535 -0
- package/src/runtime/intents.mjs +222 -0
- package/src/runtime/knowledge.mjs +86 -0
- package/src/runtime/lifecycle-hooks.mjs +1239 -0
- package/src/runtime/mcp-config.mjs +110 -0
- package/src/runtime/mcp-server.mjs +1885 -0
- package/src/runtime/palace.mjs +362 -0
- package/src/runtime/paths.mjs +58 -0
- package/src/runtime/persona-engagement.mjs +255 -0
- package/src/runtime/phase-contributions.mjs +594 -0
- package/src/runtime/policy-workspace.mjs +170 -0
- package/src/runtime/prototype-iterations.mjs +235 -0
- package/src/runtime/provider-adapters.mjs +163 -0
- package/src/runtime/repository-index.mjs +838 -0
- package/src/runtime/runs.mjs +185 -0
- package/src/runtime/security-validation.mjs +1230 -0
- package/src/runtime/starter-materialisation.mjs +1155 -0
- package/src/runtime/team-hub-client.mjs +479 -0
- package/src/runtime/team-hub-database.mjs +288 -0
- package/src/runtime/team-hub-server.mjs +191 -0
- package/src/runtime/team-hub.mjs +110 -0
- package/src/runtime/tree-sitter-index.mjs +390 -0
- package/src/runtime/version.mjs +1 -0
- package/src/runtime/work.mjs +633 -0
- package/src/salesforce-source-map.mjs +212 -0
- package/src/security-validation-config.mjs +224 -0
- package/src/solution-readiness.mjs +620 -0
- package/src/starter-materialisation-contract.mjs +407 -0
- package/src/task-graph.mjs +544 -0
- package/src/team-hub-resources.mjs +239 -0
- package/src/team-hub.mjs +242 -0
- package/src/test-scenarios.mjs +622 -0
- package/src/validation-config.mjs +289 -0
- package/templates/SPECS/1.Scope/personas/registry.yaml +12 -0
- package/templates/SPECS/5.Strategy/patterns/context-packet.md +119 -0
- package/templates/SPECS/6.Build/_tracker-template.md +16 -0
- package/templates/SPECS/pipeline.yaml +62 -0
- package/templates/discovery-answers.yaml +86 -0
- package/templates/intent-body.md +21 -0
- package/tests/fixtures/context-benchmarks.json +9 -0
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
accessSync,
|
|
5
|
+
constants,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { loadProjectConfig } from '../project.mjs';
|
|
16
|
+
import { listPersonas, personalPersonaRoot, projectPersonaRoot } from '../personas.mjs';
|
|
17
|
+
import { openRuntimeDatabase } from './database.mjs';
|
|
18
|
+
import { assessSecurityReadiness } from './security-validation.mjs';
|
|
19
|
+
|
|
20
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const installRoot = resolve(moduleDir, '../..');
|
|
22
|
+
const HANDLER_SCHEMA = 'ewai.lifecycle-handler/v1';
|
|
23
|
+
const EVENT_SCHEMA = 'ewai.lifecycle-event/v1';
|
|
24
|
+
const ACK_SCHEMA = 'ewai.lifecycle-hook-ack/v1';
|
|
25
|
+
const PROTOCOL_VERSION = '1';
|
|
26
|
+
const EVENT_SCHEMA_VERSION = '1';
|
|
27
|
+
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
|
+
const RETRY_DELAYS_MS = Object.freeze([1_000, 5_000]);
|
|
29
|
+
const AUTOMATIC_ATTEMPTS = 3;
|
|
30
|
+
const CLAIM_RECOVERY_MS = 60_000;
|
|
31
|
+
const MAX_EVENT_BYTES = 32 * 1024;
|
|
32
|
+
const MAX_FACT_STRING = 500;
|
|
33
|
+
const SAFE_TIERS = new Set(['core', 'premium', 'personal', 'project']);
|
|
34
|
+
const identifierPattern = /^[a-z0-9][a-z0-9._-]{0,159}$/;
|
|
35
|
+
const semverPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
36
|
+
const digestPattern = /^sha256:([a-f0-9]{64})$/;
|
|
37
|
+
const unsafeKeyPattern = /(secret|token|password|credential|authorization|cookie|command|argument|entrypoint|executable|working.?directory|raw|stdout|stderr|prompt|body|absolute.?path)/i;
|
|
38
|
+
|
|
39
|
+
export const LIFECYCLE_EVENT_CATALOGUE = Object.freeze([
|
|
40
|
+
Object.freeze({
|
|
41
|
+
name: 'ewai.project.discovery.completed',
|
|
42
|
+
label: 'Discovery completed',
|
|
43
|
+
description: 'The project Discovery outputs and configuration were committed.',
|
|
44
|
+
allowedFacts: Object.freeze(['status', 'selectedPacks', 'minimumStandards', 'complianceReviews', 'organisationBlueprintId']),
|
|
45
|
+
}),
|
|
46
|
+
Object.freeze({
|
|
47
|
+
name: 'ewai.project.starter.materialised',
|
|
48
|
+
label: 'Governed starter materialised',
|
|
49
|
+
description: 'An approved Governed Starter Pack was additively materialised and its canonical evidence was persisted.',
|
|
50
|
+
allowedFacts: Object.freeze([
|
|
51
|
+
'receiptId',
|
|
52
|
+
'adapterId',
|
|
53
|
+
'treeDigest',
|
|
54
|
+
'targetCount',
|
|
55
|
+
'repositoryCount',
|
|
56
|
+
'createdCount',
|
|
57
|
+
'identicalCount',
|
|
58
|
+
'completedAt',
|
|
59
|
+
]),
|
|
60
|
+
}),
|
|
61
|
+
Object.freeze({
|
|
62
|
+
name: 'ewai.meeting-evidence.promoted',
|
|
63
|
+
label: 'Meeting evidence promoted',
|
|
64
|
+
description: 'A named approver promoted a digest-bound reviewed meeting-evidence pair after canonical persistence.',
|
|
65
|
+
allowedFacts: Object.freeze(['sourceId', 'evidenceDigest', 'candidateCount', 'approvedAt']),
|
|
66
|
+
}),
|
|
67
|
+
Object.freeze({
|
|
68
|
+
name: 'ewai.knowledge-proposals.materialised',
|
|
69
|
+
label: 'Knowledge proposals materialised',
|
|
70
|
+
description: 'A named approver additively materialised a digest-bound reviewed knowledge proposal bundle after canonical persistence.',
|
|
71
|
+
allowedFacts: Object.freeze([
|
|
72
|
+
'bundleId',
|
|
73
|
+
'materialisationDigest',
|
|
74
|
+
'addedCount',
|
|
75
|
+
'alreadyCurrentCount',
|
|
76
|
+
'conflictCount',
|
|
77
|
+
'materialisedAt',
|
|
78
|
+
]),
|
|
79
|
+
}),
|
|
80
|
+
Object.freeze({
|
|
81
|
+
name: 'ewai.intent.created',
|
|
82
|
+
label: 'Intent created',
|
|
83
|
+
description: 'A project Intent Markdown document and structured state were created.',
|
|
84
|
+
allowedFacts: Object.freeze(['status', 'domain', 'slug']),
|
|
85
|
+
}),
|
|
86
|
+
...['facts.confirmed', 'evaluation.recorded', 'review.recorded', 'exception.recorded'].map((suffix) => Object.freeze({
|
|
87
|
+
name: `ewai.policy.${suffix}`,
|
|
88
|
+
label: `Policy ${suffix.replace('.', ' ')}`,
|
|
89
|
+
description: 'A bounded organisation-policy design artefact was persisted before this advisory lifecycle event.',
|
|
90
|
+
allowedFacts: Object.freeze(['status', 'intentReference', 'evidenceDigest', 'ruleId']),
|
|
91
|
+
})),
|
|
92
|
+
Object.freeze({
|
|
93
|
+
name: 'ewai.delivery.phase.entered',
|
|
94
|
+
label: 'Delivery phase entered',
|
|
95
|
+
description: 'A guarded EWAI delivery phase entered running state.',
|
|
96
|
+
allowedFacts: Object.freeze(['status', 'phase']),
|
|
97
|
+
}),
|
|
98
|
+
Object.freeze({
|
|
99
|
+
name: 'ewai.delivery.phase.completed',
|
|
100
|
+
label: 'Delivery phase completed',
|
|
101
|
+
description: 'A guarded EWAI delivery phase completed with passing gate evidence.',
|
|
102
|
+
allowedFacts: Object.freeze(['status', 'phase']),
|
|
103
|
+
}),
|
|
104
|
+
Object.freeze({
|
|
105
|
+
name: 'ewai.delivery.build.approved',
|
|
106
|
+
label: 'Build approved',
|
|
107
|
+
description: 'The durable human Build approval was recorded.',
|
|
108
|
+
allowedFacts: Object.freeze(['status', 'decision']),
|
|
109
|
+
}),
|
|
110
|
+
Object.freeze({
|
|
111
|
+
name: 'ewai.delivery.manual-qa.approved',
|
|
112
|
+
label: 'Manual QA approved',
|
|
113
|
+
description: 'The durable human Manual QA approval was recorded.',
|
|
114
|
+
allowedFacts: Object.freeze(['status', 'decision']),
|
|
115
|
+
}),
|
|
116
|
+
Object.freeze({
|
|
117
|
+
name: 'ewai.delivery.completed',
|
|
118
|
+
label: 'Delivery completed',
|
|
119
|
+
description: 'The EWAI Delivery phase completed and handed off to Manual QA.',
|
|
120
|
+
allowedFacts: Object.freeze(['status']),
|
|
121
|
+
}),
|
|
122
|
+
Object.freeze({
|
|
123
|
+
name: 'ewai.delivery.release-ready',
|
|
124
|
+
label: 'Release ready',
|
|
125
|
+
description: 'Delivery is complete and every required human gate is approved.',
|
|
126
|
+
allowedFacts: Object.freeze(['status']),
|
|
127
|
+
derived: true,
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const catalogueByName = new Map(LIFECYCLE_EVENT_CATALOGUE.map((entry) => [entry.name, entry]));
|
|
132
|
+
|
|
133
|
+
function isoNow(options = {}) {
|
|
134
|
+
const value = typeof options.now === 'function' ? options.now() : options.now;
|
|
135
|
+
return new Date(value ?? Date.now()).toISOString();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function plusMilliseconds(value, milliseconds) {
|
|
139
|
+
return new Date(new Date(value).getTime() + milliseconds).toISOString();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sha256(value) {
|
|
143
|
+
return createHash('sha256').update(value).digest('hex');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseJson(value, fallback = null) {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(value);
|
|
149
|
+
} catch {
|
|
150
|
+
return fallback;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function assertPlainObject(value, label) {
|
|
155
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
156
|
+
throw new Error(`${label} must be an object`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertIdentifier(value, label, options = {}) {
|
|
161
|
+
const clean = String(value ?? '').trim();
|
|
162
|
+
const minimum = options.minimum ?? 1;
|
|
163
|
+
const maximum = options.maximum ?? 160;
|
|
164
|
+
if (!identifierPattern.test(clean) || clean.length < minimum || clean.length > maximum) {
|
|
165
|
+
throw new Error(`${label} is invalid: ${clean}`);
|
|
166
|
+
}
|
|
167
|
+
return clean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function assertShortText(value, label, maximum = 160) {
|
|
171
|
+
const clean = String(value ?? '').trim();
|
|
172
|
+
if (!clean || clean.length > maximum || /[\u0000-\u001f]/.test(clean)) {
|
|
173
|
+
throw new Error(`${label} must be between 1 and ${maximum} safe characters`);
|
|
174
|
+
}
|
|
175
|
+
return clean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isWithin(root, candidate) {
|
|
179
|
+
const rel = relative(root, candidate);
|
|
180
|
+
return rel === '' || (rel && !rel.startsWith('..') && !isAbsolute(rel));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function safeRelativeReference(projectRoot, value) {
|
|
184
|
+
const clean = String(value ?? '').trim().replaceAll('\\', '/');
|
|
185
|
+
if (!clean || clean.length > 400 || isAbsolute(clean) || clean.startsWith('../') || clean.includes('/../') || clean.includes('\u0000')) {
|
|
186
|
+
throw new Error(`Lifecycle evidence reference must be project-relative: ${clean}`);
|
|
187
|
+
}
|
|
188
|
+
const absolute = resolve(projectRoot, clean);
|
|
189
|
+
if (!isWithin(projectRoot, absolute)) throw new Error(`Lifecycle evidence reference escapes the project: ${clean}`);
|
|
190
|
+
return clean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function safeValue(value, key, depth = 0) {
|
|
194
|
+
if (unsafeKeyPattern.test(key)) throw new Error(`Lifecycle field is not allowed: ${key}`);
|
|
195
|
+
if (value === null || typeof value === 'boolean') return value;
|
|
196
|
+
if (typeof value === 'number') {
|
|
197
|
+
if (!Number.isFinite(value)) throw new Error(`Lifecycle field is not finite: ${key}`);
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
if (typeof value === 'string') {
|
|
201
|
+
const clean = value.trim();
|
|
202
|
+
if (clean.length > MAX_FACT_STRING || /[\u0000]/.test(clean)) throw new Error(`Lifecycle field is unsafe or too long: ${key}`);
|
|
203
|
+
return clean;
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
if (depth > 1 || value.length > 40) throw new Error(`Lifecycle array is too large: ${key}`);
|
|
207
|
+
return value.map((item, index) => safeValue(item, `${key}.${index}`, depth + 1));
|
|
208
|
+
}
|
|
209
|
+
if (value && typeof value === 'object') {
|
|
210
|
+
if (depth > 1 || Object.keys(value).length > 30) throw new Error(`Lifecycle object is too large: ${key}`);
|
|
211
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, safeValue(child, childKey, depth + 1)]));
|
|
212
|
+
}
|
|
213
|
+
throw new Error(`Lifecycle field has unsupported value: ${key}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function safeFacts(eventName, facts = {}) {
|
|
217
|
+
assertPlainObject(facts, 'Lifecycle facts');
|
|
218
|
+
const allowed = new Set(catalogueByName.get(eventName).allowedFacts);
|
|
219
|
+
const output = {};
|
|
220
|
+
for (const [key, value] of Object.entries(facts)) {
|
|
221
|
+
if (!allowed.has(key)) throw new Error(`Lifecycle fact is not allowed for ${eventName}: ${key}`);
|
|
222
|
+
output[key] = safeValue(value, key);
|
|
223
|
+
}
|
|
224
|
+
return output;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function inferredTier(reference, path = '') {
|
|
228
|
+
if (path.includes('/premium-personas/')) return 'premium';
|
|
229
|
+
if (path.includes('/1.Scope/personas/project/')) return 'project';
|
|
230
|
+
if (path.includes('/.ewai/personas/')) return 'personal';
|
|
231
|
+
if (String(reference).startsWith('project.')) return 'project';
|
|
232
|
+
if (String(reference).includes('premium')) return 'premium';
|
|
233
|
+
return 'core';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function personaCatalogue(projectRoot) {
|
|
237
|
+
const roots = [
|
|
238
|
+
resolve(installRoot, 'packs/personas/core/personas'),
|
|
239
|
+
resolve(homedir(), '.ewai/packs/ewai.personas.professional/premium-personas'),
|
|
240
|
+
personalPersonaRoot(),
|
|
241
|
+
projectPersonaRoot(projectRoot),
|
|
242
|
+
];
|
|
243
|
+
const ranks = { project: 4, premium: 3, personal: 2, core: 1 };
|
|
244
|
+
const found = new Map();
|
|
245
|
+
for (const persona of listPersonas(roots)) {
|
|
246
|
+
const tier = SAFE_TIERS.has(persona.tier) ? persona.tier : inferredTier(persona.id, persona.path);
|
|
247
|
+
const existing = found.get(persona.id);
|
|
248
|
+
if (!existing || ranks[tier] > ranks[existing.tier]) found.set(persona.id, { ...persona, tier });
|
|
249
|
+
}
|
|
250
|
+
return found;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function resolveLifecyclePersonas(projectRoot, attachments = []) {
|
|
254
|
+
const catalogue = personaCatalogue(projectRoot);
|
|
255
|
+
return (Array.isArray(attachments) ? attachments : []).slice(0, 12).map((attachment) => {
|
|
256
|
+
const source = typeof attachment === 'string' ? { ref: attachment } : attachment ?? {};
|
|
257
|
+
const id = assertShortText(source.id ?? source.ref, 'Persona ID');
|
|
258
|
+
const persona = catalogue.get(id);
|
|
259
|
+
const tier = SAFE_TIERS.has(source.tier) ? source.tier : persona?.tier ?? inferredTier(id);
|
|
260
|
+
const role = String(source.role ?? '').trim();
|
|
261
|
+
return {
|
|
262
|
+
id,
|
|
263
|
+
name: assertShortText(source.name ?? persona?.name ?? id, 'Persona name'),
|
|
264
|
+
tier,
|
|
265
|
+
reason: assertShortText(
|
|
266
|
+
source.reason ?? source.engagementReason ?? (role ? `Engaged as ${role} for this lifecycle moment.` : 'Attached to the canonical lifecycle context.'),
|
|
267
|
+
'Persona engagement reason',
|
|
268
|
+
300,
|
|
269
|
+
),
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function safeScope(scope = {}) {
|
|
275
|
+
assertPlainObject(scope, 'Lifecycle scope');
|
|
276
|
+
const allowed = new Set(['intent', 'delivery', 'phase']);
|
|
277
|
+
const result = {};
|
|
278
|
+
for (const [key, value] of Object.entries(scope)) {
|
|
279
|
+
if (!allowed.has(key)) throw new Error(`Lifecycle scope is not allowed: ${key}`);
|
|
280
|
+
result[key] = assertShortText(value, `Lifecycle ${key}`, 200);
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function validatePattern(pattern) {
|
|
286
|
+
const clean = String(pattern ?? '').trim();
|
|
287
|
+
const matched = LIFECYCLE_EVENT_CATALOGUE.some((entry) => (
|
|
288
|
+
clean === entry.name || (clean.endsWith('.*') && entry.name.startsWith(clean.slice(0, -1)))
|
|
289
|
+
));
|
|
290
|
+
if (!matched) throw new Error(`Unsupported lifecycle event pattern: ${clean}`);
|
|
291
|
+
return clean;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function patternMatches(pattern, eventName) {
|
|
295
|
+
return pattern === eventName || (pattern.endsWith('.*') && eventName.startsWith(pattern.slice(0, -1)));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function validateManifestShape(manifest) {
|
|
299
|
+
assertPlainObject(manifest, 'Lifecycle handler manifest');
|
|
300
|
+
const allowedKeys = new Set(['schema', 'id', 'name', 'publisher', 'version', 'compatibility', 'entrypoint', 'digest', 'events', 'limits', 'description']);
|
|
301
|
+
for (const key of Object.keys(manifest)) {
|
|
302
|
+
if (!allowedKeys.has(key)) throw new Error(`Lifecycle handler manifest field is not allowed: ${key}`);
|
|
303
|
+
}
|
|
304
|
+
if (manifest.schema !== HANDLER_SCHEMA) throw new Error(`Unsupported lifecycle handler schema: ${manifest.schema ?? ''}`);
|
|
305
|
+
const id = assertIdentifier(manifest.id, 'Lifecycle handler ID', { minimum: 3, maximum: 128 });
|
|
306
|
+
const name = assertShortText(manifest.name, 'Lifecycle handler name', 120);
|
|
307
|
+
assertPlainObject(manifest.publisher, 'Lifecycle handler publisher');
|
|
308
|
+
for (const key of Object.keys(manifest.publisher)) {
|
|
309
|
+
if (!['id', 'name'].includes(key)) throw new Error(`Lifecycle handler publisher field is not allowed: ${key}`);
|
|
310
|
+
}
|
|
311
|
+
const publisher = {
|
|
312
|
+
id: assertIdentifier(manifest.publisher.id, 'Lifecycle handler publisher ID', { minimum: 2, maximum: 80 }),
|
|
313
|
+
name: assertShortText(manifest.publisher.name, 'Lifecycle handler publisher name', 120),
|
|
314
|
+
};
|
|
315
|
+
const version = String(manifest.version ?? '').trim();
|
|
316
|
+
if (!semverPattern.test(version)) throw new Error(`Lifecycle handler version must be semantic: ${version}`);
|
|
317
|
+
assertPlainObject(manifest.compatibility, 'Lifecycle handler compatibility');
|
|
318
|
+
for (const key of Object.keys(manifest.compatibility)) {
|
|
319
|
+
if (!['protocols', 'eventSchemas'].includes(key)) throw new Error(`Lifecycle handler compatibility field is not allowed: ${key}`);
|
|
320
|
+
}
|
|
321
|
+
if (!Array.isArray(manifest.compatibility.protocols) || !Array.isArray(manifest.compatibility.eventSchemas)) {
|
|
322
|
+
throw new Error('Lifecycle handler compatibility versions must be arrays');
|
|
323
|
+
}
|
|
324
|
+
const protocols = [...new Set((manifest.compatibility.protocols ?? []).map(String))];
|
|
325
|
+
const eventSchemas = [...new Set((manifest.compatibility.eventSchemas ?? []).map(String))];
|
|
326
|
+
if (!protocols.includes(PROTOCOL_VERSION)) throw new Error(`Lifecycle handler does not support protocol ${PROTOCOL_VERSION}`);
|
|
327
|
+
if (!eventSchemas.includes(EVENT_SCHEMA_VERSION)) throw new Error(`Lifecycle handler does not support event schema ${EVENT_SCHEMA_VERSION}`);
|
|
328
|
+
const entrypoint = String(manifest.entrypoint ?? '').trim().replaceAll('\\', '/');
|
|
329
|
+
if (!entrypoint || entrypoint.length > 240 || isAbsolute(entrypoint) || entrypoint.startsWith('../') || entrypoint.includes('/../')) {
|
|
330
|
+
throw new Error('Lifecycle handler entrypoint must be a bounded relative path');
|
|
331
|
+
}
|
|
332
|
+
const digestMatch = String(manifest.digest ?? '').match(digestPattern);
|
|
333
|
+
if (!digestMatch) throw new Error('Lifecycle handler digest must be sha256:<64 lowercase hex characters>');
|
|
334
|
+
if (!Array.isArray(manifest.events) || !manifest.events.length) throw new Error('Lifecycle handler must declare at least one supported event pattern');
|
|
335
|
+
const events = [...new Set(manifest.events.map(validatePattern))].sort();
|
|
336
|
+
assertPlainObject(manifest.limits, 'Lifecycle handler limits');
|
|
337
|
+
for (const key of Object.keys(manifest.limits)) {
|
|
338
|
+
if (!['timeoutMs', 'maxOutputBytes'].includes(key)) throw new Error(`Lifecycle handler limit field is not allowed: ${key}`);
|
|
339
|
+
}
|
|
340
|
+
const timeoutMs = Number(manifest.limits.timeoutMs);
|
|
341
|
+
const maxOutputBytes = Number(manifest.limits.maxOutputBytes);
|
|
342
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 30_000) throw new Error('Lifecycle handler timeoutMs must be from 100 to 30000');
|
|
343
|
+
if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 256 || maxOutputBytes > 65_536) throw new Error('Lifecycle handler maxOutputBytes must be from 256 to 65536');
|
|
344
|
+
return {
|
|
345
|
+
schema: HANDLER_SCHEMA,
|
|
346
|
+
id,
|
|
347
|
+
name,
|
|
348
|
+
publisher,
|
|
349
|
+
version,
|
|
350
|
+
compatibility: { protocols, eventSchemas },
|
|
351
|
+
entrypoint,
|
|
352
|
+
digest: `sha256:${digestMatch[1]}`,
|
|
353
|
+
events,
|
|
354
|
+
limits: { timeoutMs, maxOutputBytes },
|
|
355
|
+
...(manifest.description ? { description: assertShortText(manifest.description, 'Lifecycle handler description', 500) } : {}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function inspectHandlerPackage(folder) {
|
|
360
|
+
const requestedRoot = resolve(String(folder ?? ''));
|
|
361
|
+
if (!existsSync(requestedRoot) || !lstatSync(requestedRoot).isDirectory()) throw new Error('Lifecycle handler folder does not exist or is not a directory');
|
|
362
|
+
if (lstatSync(requestedRoot).isSymbolicLink()) throw new Error('Lifecycle handler root cannot be a symbolic link');
|
|
363
|
+
const trustedRoot = realpathSync(requestedRoot);
|
|
364
|
+
const manifestPath = resolve(trustedRoot, 'lifecycle-handler.json');
|
|
365
|
+
if (!existsSync(manifestPath) || !lstatSync(manifestPath).isFile() || lstatSync(manifestPath).isSymbolicLink()) {
|
|
366
|
+
throw new Error('Lifecycle handler manifest must be a regular non-symlink lifecycle-handler.json file');
|
|
367
|
+
}
|
|
368
|
+
const manifestBytes = readFileSync(manifestPath);
|
|
369
|
+
let parsed;
|
|
370
|
+
try {
|
|
371
|
+
parsed = JSON.parse(manifestBytes.toString('utf8'));
|
|
372
|
+
} catch {
|
|
373
|
+
throw new Error('Lifecycle handler manifest is not valid JSON');
|
|
374
|
+
}
|
|
375
|
+
const manifest = validateManifestShape(parsed);
|
|
376
|
+
const requestedEntry = resolve(trustedRoot, manifest.entrypoint);
|
|
377
|
+
if (!isWithin(trustedRoot, requestedEntry) || !existsSync(requestedEntry)) throw new Error('Lifecycle handler entrypoint escapes the trusted root or is missing');
|
|
378
|
+
if (lstatSync(requestedEntry).isSymbolicLink() || !lstatSync(requestedEntry).isFile()) {
|
|
379
|
+
throw new Error('Lifecycle handler entrypoint must be a regular non-symlink file');
|
|
380
|
+
}
|
|
381
|
+
const entrypointPath = realpathSync(requestedEntry);
|
|
382
|
+
if (!isWithin(trustedRoot, entrypointPath)) throw new Error('Lifecycle handler entrypoint resolves outside the trusted root');
|
|
383
|
+
accessSync(entrypointPath, constants.X_OK);
|
|
384
|
+
const entryBytes = readFileSync(entrypointPath);
|
|
385
|
+
const entryDigest = sha256(entryBytes);
|
|
386
|
+
if (manifest.digest !== `sha256:${entryDigest}`) throw new Error('Lifecycle handler entrypoint digest does not match the manifest');
|
|
387
|
+
const manifestDigest = sha256(manifestBytes);
|
|
388
|
+
const packageDigest = sha256(Buffer.concat([manifestBytes, Buffer.from([0]), entryBytes]));
|
|
389
|
+
return { manifest, trustedRoot, entrypointPath, manifestDigest, packageDigest };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function safeHandler(packageInfo) {
|
|
393
|
+
const { manifest, manifestDigest, packageDigest } = packageInfo;
|
|
394
|
+
return {
|
|
395
|
+
schema: 'ewai.lifecycle-handler-validation/v1',
|
|
396
|
+
id: manifest.id,
|
|
397
|
+
name: manifest.name,
|
|
398
|
+
publisher: manifest.publisher,
|
|
399
|
+
version: manifest.version,
|
|
400
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
401
|
+
eventSchemaVersion: EVENT_SCHEMA_VERSION,
|
|
402
|
+
events: manifest.events,
|
|
403
|
+
limits: manifest.limits,
|
|
404
|
+
manifestDigest,
|
|
405
|
+
packageDigest,
|
|
406
|
+
valid: true,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function validateLifecycleHandlerPackage(_projectRoot, folder) {
|
|
411
|
+
return safeHandler(inspectHandlerPackage(folder));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function registerLifecycleHandler(projectRoot, folder, input = {}) {
|
|
415
|
+
if (input.confirmed !== true) throw new Error('Lifecycle handler registration requires explicit confirmation');
|
|
416
|
+
const inspected = inspectHandlerPackage(folder);
|
|
417
|
+
const database = openRuntimeDatabase(projectRoot);
|
|
418
|
+
const timestamp = isoNow(input);
|
|
419
|
+
try {
|
|
420
|
+
if (database.prepare('SELECT id FROM lifecycle_handlers WHERE id = ?').get(inspected.manifest.id)) {
|
|
421
|
+
throw new Error(`Lifecycle handler ID is already registered: ${inspected.manifest.id}`);
|
|
422
|
+
}
|
|
423
|
+
database.prepare(`
|
|
424
|
+
INSERT INTO lifecycle_handlers (
|
|
425
|
+
id, name, publisher_id, publisher_name, version, protocol_version,
|
|
426
|
+
event_schema_version, trusted_root, entrypoint, manifest_digest,
|
|
427
|
+
package_digest, manifest_json, registered_at, updated_at
|
|
428
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
429
|
+
`).run(
|
|
430
|
+
inspected.manifest.id,
|
|
431
|
+
inspected.manifest.name,
|
|
432
|
+
inspected.manifest.publisher.id,
|
|
433
|
+
inspected.manifest.publisher.name,
|
|
434
|
+
inspected.manifest.version,
|
|
435
|
+
PROTOCOL_VERSION,
|
|
436
|
+
EVENT_SCHEMA_VERSION,
|
|
437
|
+
inspected.trustedRoot,
|
|
438
|
+
inspected.manifest.entrypoint,
|
|
439
|
+
inspected.manifestDigest,
|
|
440
|
+
inspected.packageDigest,
|
|
441
|
+
JSON.stringify(inspected.manifest),
|
|
442
|
+
timestamp,
|
|
443
|
+
timestamp,
|
|
444
|
+
);
|
|
445
|
+
return { ...safeHandler(inspected), registeredAt: timestamp };
|
|
446
|
+
} finally {
|
|
447
|
+
database.close();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function subscribeLifecycleHandler(projectRoot, handlerId, patterns, input = {}) {
|
|
452
|
+
if (input.confirmed !== true) throw new Error('Lifecycle handler subscription requires explicit confirmation');
|
|
453
|
+
const requested = [...new Set((Array.isArray(patterns) ? patterns : String(patterns ?? '').split(','))
|
|
454
|
+
.map(validatePattern))].sort();
|
|
455
|
+
if (!requested.length) throw new Error('Lifecycle handler subscription requires at least one event pattern');
|
|
456
|
+
const database = openRuntimeDatabase(projectRoot);
|
|
457
|
+
const timestamp = isoNow(input);
|
|
458
|
+
try {
|
|
459
|
+
const handler = database.prepare('SELECT * FROM lifecycle_handlers WHERE id = ?').get(handlerId);
|
|
460
|
+
if (!handler) throw new Error(`Lifecycle handler is not registered: ${handlerId}`);
|
|
461
|
+
const manifest = parseJson(handler.manifest_json, {});
|
|
462
|
+
for (const pattern of requested) {
|
|
463
|
+
const requestedEvents = LIFECYCLE_EVENT_CATALOGUE.filter((entry) => patternMatches(pattern, entry.name));
|
|
464
|
+
if (requestedEvents.some((entry) => !(manifest.events ?? []).some((declared) => patternMatches(declared, entry.name)))) {
|
|
465
|
+
throw new Error(`Lifecycle handler manifest does not declare support for pattern: ${pattern}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const patternsJson = JSON.stringify(requested);
|
|
469
|
+
const existing = database.prepare('SELECT * FROM lifecycle_subscriptions WHERE handler_id = ? AND patterns_json = ?').get(handlerId, patternsJson);
|
|
470
|
+
if (existing) {
|
|
471
|
+
if (!existing.enabled) {
|
|
472
|
+
database.prepare('UPDATE lifecycle_subscriptions SET enabled = 1, disabled_at = NULL, updated_at = ? WHERE id = ?').run(timestamp, existing.id);
|
|
473
|
+
}
|
|
474
|
+
return { schema: 'ewai.lifecycle-subscription/v1', id: existing.id, handlerId, patterns: requested, enabled: true, created: false };
|
|
475
|
+
}
|
|
476
|
+
const id = randomUUID();
|
|
477
|
+
database.prepare(`
|
|
478
|
+
INSERT INTO lifecycle_subscriptions (id, handler_id, patterns_json, enabled, created_at, updated_at)
|
|
479
|
+
VALUES (?, ?, ?, 1, ?, ?)
|
|
480
|
+
`).run(id, handlerId, patternsJson, timestamp, timestamp);
|
|
481
|
+
return { schema: 'ewai.lifecycle-subscription/v1', id, handlerId, patterns: requested, enabled: true, created: true };
|
|
482
|
+
} finally {
|
|
483
|
+
database.close();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function projectIdentity(projectRoot) {
|
|
488
|
+
const { config, paths } = loadProjectConfig(projectRoot);
|
|
489
|
+
return {
|
|
490
|
+
id: `project-${sha256(realpathSync(paths.projectRoot)).slice(0, 16)}`,
|
|
491
|
+
name: assertShortText(config.project?.name ?? 'EWAI project', 'Project name', 160),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function eventFromRow(row, idempotencyKey = '') {
|
|
496
|
+
return {
|
|
497
|
+
schema: idempotencyKey ? EVENT_SCHEMA : 'ewai.lifecycle-event-record/v1',
|
|
498
|
+
id: row.id,
|
|
499
|
+
name: row.name,
|
|
500
|
+
occurredAt: row.occurred_at,
|
|
501
|
+
project: parseJson(row.project_json, {}),
|
|
502
|
+
scope: parseJson(row.scope_json, {}),
|
|
503
|
+
facts: parseJson(row.facts_json, {}),
|
|
504
|
+
source: { key: row.source_key, ...(row.source_revision ? { revision: row.source_revision } : {}) },
|
|
505
|
+
evidence: parseJson(row.evidence_json, []),
|
|
506
|
+
personas: parseJson(row.personas_json, []),
|
|
507
|
+
stream: { id: row.stream_id, sequence: row.stream_sequence },
|
|
508
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function publishLifecycleEvent(projectRoot, eventName, input = {}) {
|
|
513
|
+
const catalogue = catalogueByName.get(eventName);
|
|
514
|
+
if (!catalogue) throw new Error(`Unsupported lifecycle event: ${eventName}`);
|
|
515
|
+
const sourceKey = assertShortText(input.sourceKey, 'Lifecycle source key', 400);
|
|
516
|
+
const occurredAt = new Date(input.occurredAt ?? isoNow(input)).toISOString();
|
|
517
|
+
const sourceRevision = String(input.sourceRevision ?? '').trim().slice(0, 200);
|
|
518
|
+
const scope = safeScope(input.scope ?? {});
|
|
519
|
+
const facts = safeFacts(eventName, input.facts ?? {});
|
|
520
|
+
const evidence = [...new Set((input.evidence ?? []).map((value) => safeRelativeReference(resolve(projectRoot), value)))].slice(0, 24);
|
|
521
|
+
const personas = resolveLifecyclePersonas(projectRoot, input.personas ?? []);
|
|
522
|
+
const project = projectIdentity(projectRoot);
|
|
523
|
+
const streamId = assertShortText(input.streamId ?? (scope.delivery ? `delivery:${scope.delivery}` : scope.intent ? `intent:${scope.intent}` : 'project'), 'Lifecycle stream ID', 200);
|
|
524
|
+
const eventBytes = Buffer.byteLength(JSON.stringify({ project, scope, facts, evidence, personas }), 'utf8');
|
|
525
|
+
if (eventBytes > MAX_EVENT_BYTES) throw new Error('Lifecycle event exceeds the safe envelope size');
|
|
526
|
+
const database = openRuntimeDatabase(projectRoot);
|
|
527
|
+
try {
|
|
528
|
+
const existing = database.prepare('SELECT * FROM lifecycle_events WHERE source_key = ?').get(sourceKey);
|
|
529
|
+
if (existing) return { schema: 'ewai.lifecycle-publication/v1', created: false, event: eventFromRow(existing), deliveryCount: 0 };
|
|
530
|
+
database.exec('BEGIN IMMEDIATE;');
|
|
531
|
+
try {
|
|
532
|
+
const duplicate = database.prepare('SELECT * FROM lifecycle_events WHERE source_key = ?').get(sourceKey);
|
|
533
|
+
if (duplicate) {
|
|
534
|
+
database.exec('COMMIT;');
|
|
535
|
+
return { schema: 'ewai.lifecycle-publication/v1', created: false, event: eventFromRow(duplicate), deliveryCount: 0 };
|
|
536
|
+
}
|
|
537
|
+
const sequence = Number(database.prepare('SELECT COALESCE(MAX(stream_sequence), 0) AS value FROM lifecycle_events WHERE stream_id = ?').get(streamId).value) + 1;
|
|
538
|
+
const id = randomUUID();
|
|
539
|
+
const createdAt = isoNow(input);
|
|
540
|
+
database.prepare(`
|
|
541
|
+
INSERT INTO lifecycle_events (
|
|
542
|
+
id, name, schema_version, occurred_at, source_key, source_revision,
|
|
543
|
+
project_json, scope_json, facts_json, evidence_json, personas_json,
|
|
544
|
+
stream_id, stream_sequence, created_at
|
|
545
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
546
|
+
`).run(
|
|
547
|
+
id, eventName, EVENT_SCHEMA_VERSION, occurredAt, sourceKey, sourceRevision,
|
|
548
|
+
JSON.stringify(project), JSON.stringify(scope), JSON.stringify(facts), JSON.stringify(evidence), JSON.stringify(personas),
|
|
549
|
+
streamId, sequence, createdAt,
|
|
550
|
+
);
|
|
551
|
+
const subscriptions = database.prepare(`
|
|
552
|
+
SELECT s.*, h.name AS handler_name, h.publisher_id, h.publisher_name, h.version,
|
|
553
|
+
h.protocol_version, h.event_schema_version, h.trusted_root, h.entrypoint,
|
|
554
|
+
h.manifest_digest, h.package_digest, h.manifest_json
|
|
555
|
+
FROM lifecycle_subscriptions s
|
|
556
|
+
JOIN lifecycle_handlers h ON h.id = s.handler_id
|
|
557
|
+
WHERE s.enabled = 1
|
|
558
|
+
`).all();
|
|
559
|
+
let deliveryCount = 0;
|
|
560
|
+
for (const subscription of subscriptions) {
|
|
561
|
+
const patterns = parseJson(subscription.patterns_json, []);
|
|
562
|
+
if (!patterns.some((pattern) => patternMatches(pattern, eventName))) continue;
|
|
563
|
+
const deliveryId = randomUUID();
|
|
564
|
+
const idempotencyKey = `ewai-${sha256(`${id}:${subscription.id}`).slice(0, 48)}`;
|
|
565
|
+
const snapshot = {
|
|
566
|
+
id: subscription.handler_id,
|
|
567
|
+
name: subscription.handler_name,
|
|
568
|
+
publisher: { id: subscription.publisher_id, name: subscription.publisher_name },
|
|
569
|
+
version: subscription.version,
|
|
570
|
+
protocolVersion: subscription.protocol_version,
|
|
571
|
+
eventSchemaVersion: subscription.event_schema_version,
|
|
572
|
+
trustedRoot: subscription.trusted_root,
|
|
573
|
+
entrypoint: subscription.entrypoint,
|
|
574
|
+
manifestDigest: subscription.manifest_digest,
|
|
575
|
+
packageDigest: subscription.package_digest,
|
|
576
|
+
manifest: parseJson(subscription.manifest_json, {}),
|
|
577
|
+
};
|
|
578
|
+
database.prepare(`
|
|
579
|
+
INSERT INTO lifecycle_deliveries (
|
|
580
|
+
id, event_id, subscription_id, handler_snapshot_json, idempotency_key,
|
|
581
|
+
status, automatic_attempt_count, manual_pending, next_attempt_at, created_at, updated_at
|
|
582
|
+
) VALUES (?, ?, ?, ?, ?, 'queued', 0, 0, ?, ?, ?)
|
|
583
|
+
`).run(deliveryId, id, subscription.id, JSON.stringify(snapshot), idempotencyKey, createdAt, createdAt, createdAt);
|
|
584
|
+
deliveryCount += 1;
|
|
585
|
+
}
|
|
586
|
+
database.exec('COMMIT;');
|
|
587
|
+
const event = database.prepare('SELECT * FROM lifecycle_events WHERE id = ?').get(id);
|
|
588
|
+
return { schema: 'ewai.lifecycle-publication/v1', created: true, event: eventFromRow(event), deliveryCount };
|
|
589
|
+
} catch (error) {
|
|
590
|
+
database.exec('ROLLBACK;');
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
} finally {
|
|
594
|
+
database.close();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function publishLifecycleEventSafely(projectRoot, eventName, input = {}) {
|
|
599
|
+
try {
|
|
600
|
+
return publishLifecycleEvent(projectRoot, eventName, input);
|
|
601
|
+
} catch {
|
|
602
|
+
return { schema: 'ewai.lifecycle-publication/v1', created: false, event: null, deliveryCount: 0, status: 'publication-failed' };
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function validateAcknowledgement(value) {
|
|
607
|
+
assertPlainObject(value, 'Lifecycle acknowledgement');
|
|
608
|
+
const allowed = new Set(['schema', 'status', 'code', 'message', 'metadata']);
|
|
609
|
+
for (const key of Object.keys(value)) {
|
|
610
|
+
if (!allowed.has(key)) throw new Error(`Lifecycle acknowledgement field is not allowed: ${key}`);
|
|
611
|
+
}
|
|
612
|
+
if (value.schema !== ACK_SCHEMA) throw new Error('Lifecycle acknowledgement schema is incompatible');
|
|
613
|
+
if (!['accepted', 'rejected'].includes(value.status)) throw new Error('Lifecycle acknowledgement status must be accepted or rejected');
|
|
614
|
+
const acknowledgement = { schema: ACK_SCHEMA, status: value.status };
|
|
615
|
+
if (value.code !== undefined) acknowledgement.code = assertIdentifier(value.code, 'Lifecycle acknowledgement code', { maximum: 80 });
|
|
616
|
+
if (value.message !== undefined) acknowledgement.message = assertShortText(value.message, 'Lifecycle acknowledgement message', 500);
|
|
617
|
+
if (value.metadata !== undefined) {
|
|
618
|
+
assertPlainObject(value.metadata, 'Lifecycle acknowledgement metadata');
|
|
619
|
+
if (Object.keys(value.metadata).length > 12) throw new Error('Lifecycle acknowledgement metadata has too many fields');
|
|
620
|
+
acknowledgement.metadata = Object.fromEntries(Object.entries(value.metadata).map(([key, item]) => {
|
|
621
|
+
if (unsafeKeyPattern.test(key)) throw new Error(`Lifecycle acknowledgement metadata field is not allowed: ${key}`);
|
|
622
|
+
if (!['string', 'number', 'boolean'].includes(typeof item) && item !== null) throw new Error(`Lifecycle acknowledgement metadata must be scalar: ${key}`);
|
|
623
|
+
return [key, typeof item === 'string' ? assertShortText(item, `Lifecycle acknowledgement metadata ${key}`, 200) : item];
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
return acknowledgement;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function invokeLifecycleHandler(snapshot, event, options = {}) {
|
|
630
|
+
const limits = snapshot.manifest?.limits ?? { timeoutMs: 15_000, maxOutputBytes: 16_384 };
|
|
631
|
+
return new Promise((resolvePromise) => {
|
|
632
|
+
const started = Date.now();
|
|
633
|
+
let stdout = Buffer.alloc(0);
|
|
634
|
+
let observedBytes = 0;
|
|
635
|
+
let timedOut = false;
|
|
636
|
+
let tooLarge = false;
|
|
637
|
+
let settled = false;
|
|
638
|
+
const finish = (result) => {
|
|
639
|
+
if (settled) return;
|
|
640
|
+
settled = true;
|
|
641
|
+
resolvePromise({ ...result, durationMs: Date.now() - started });
|
|
642
|
+
};
|
|
643
|
+
let child;
|
|
644
|
+
try {
|
|
645
|
+
child = (options.spawn ?? spawn)(resolve(snapshot.trustedRoot, snapshot.entrypoint), [], {
|
|
646
|
+
cwd: snapshot.trustedRoot,
|
|
647
|
+
env: {
|
|
648
|
+
PATH: process.env.PATH ?? '',
|
|
649
|
+
LANG: process.env.LANG ?? 'C',
|
|
650
|
+
LC_ALL: process.env.LC_ALL ?? '',
|
|
651
|
+
},
|
|
652
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
653
|
+
shell: false,
|
|
654
|
+
});
|
|
655
|
+
} catch {
|
|
656
|
+
finish({ ok: false, code: 'handler-start-failed', message: 'The lifecycle handler could not be started.' });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const observe = (chunk, capture) => {
|
|
660
|
+
observedBytes += chunk.length;
|
|
661
|
+
if (observedBytes > limits.maxOutputBytes) {
|
|
662
|
+
tooLarge = true;
|
|
663
|
+
child.kill('SIGTERM');
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (capture) stdout = Buffer.concat([stdout, chunk]);
|
|
667
|
+
};
|
|
668
|
+
child.stdout.on('data', (chunk) => observe(chunk, true));
|
|
669
|
+
child.stderr.on('data', (chunk) => observe(chunk, false));
|
|
670
|
+
child.stdin.once('error', () => {
|
|
671
|
+
// Early handler exit can close stdin before the bounded event write completes.
|
|
672
|
+
});
|
|
673
|
+
child.once('error', () => finish({ ok: false, code: 'handler-start-failed', message: 'The lifecycle handler could not be started.' }));
|
|
674
|
+
const timer = setTimeout(() => {
|
|
675
|
+
timedOut = true;
|
|
676
|
+
child.kill('SIGTERM');
|
|
677
|
+
setTimeout(() => child.kill('SIGKILL'), 1_000).unref();
|
|
678
|
+
}, limits.timeoutMs);
|
|
679
|
+
timer.unref();
|
|
680
|
+
child.once('close', (exitCode, signal) => {
|
|
681
|
+
clearTimeout(timer);
|
|
682
|
+
if (tooLarge) return finish({ ok: false, code: 'handler-output-too-large', message: 'The lifecycle handler acknowledgement exceeded its configured limit.' });
|
|
683
|
+
if (timedOut) return finish({ ok: false, code: 'handler-timeout', message: 'The lifecycle handler exceeded its configured timeout.' });
|
|
684
|
+
if (signal) return finish({ ok: false, code: 'handler-signal', message: 'The lifecycle handler ended before acknowledging the event.' });
|
|
685
|
+
if (exitCode !== 0) return finish({ ok: false, code: 'handler-exit', message: 'The lifecycle handler returned a non-zero exit status.' });
|
|
686
|
+
let acknowledgement;
|
|
687
|
+
try {
|
|
688
|
+
acknowledgement = validateAcknowledgement(JSON.parse(stdout.toString('utf8')));
|
|
689
|
+
} catch {
|
|
690
|
+
return finish({ ok: false, code: 'handler-ack-invalid', message: 'The lifecycle handler returned an invalid acknowledgement.' });
|
|
691
|
+
}
|
|
692
|
+
if (acknowledgement.status === 'rejected') {
|
|
693
|
+
return finish({ ok: false, rejected: true, code: acknowledgement.code || 'handler-rejected', message: acknowledgement.message || 'The lifecycle handler rejected the event.', acknowledgement });
|
|
694
|
+
}
|
|
695
|
+
return finish({ ok: true, code: acknowledgement.code || 'accepted', message: acknowledgement.message || 'Event received.', acknowledgement });
|
|
696
|
+
});
|
|
697
|
+
child.stdin.end(`${JSON.stringify(event)}\n`);
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function verifyHandlerSnapshot(snapshot) {
|
|
702
|
+
const inspected = inspectHandlerPackage(snapshot.trustedRoot);
|
|
703
|
+
if (
|
|
704
|
+
inspected.manifest.id !== snapshot.id
|
|
705
|
+
|| inspected.manifest.version !== snapshot.version
|
|
706
|
+
|| inspected.manifest.entrypoint !== snapshot.entrypoint
|
|
707
|
+
|| inspected.manifestDigest !== snapshot.manifestDigest
|
|
708
|
+
|| inspected.packageDigest !== snapshot.packageDigest
|
|
709
|
+
) {
|
|
710
|
+
throw new Error('Lifecycle handler package identity or digest changed after registration');
|
|
711
|
+
}
|
|
712
|
+
return inspected;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function recordAttempt(database, delivery, kind, result, timestamp) {
|
|
716
|
+
const last = database.prepare('SELECT COALESCE(MAX(ordinal), 0) AS value FROM lifecycle_attempts WHERE delivery_id = ?').get(delivery.id);
|
|
717
|
+
const ordinal = Number(last.value) + 1;
|
|
718
|
+
database.prepare(`
|
|
719
|
+
INSERT INTO lifecycle_attempts (
|
|
720
|
+
id, delivery_id, kind, ordinal, started_at, completed_at, status, code, message, duration_ms
|
|
721
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
722
|
+
`).run(
|
|
723
|
+
randomUUID(), delivery.id, kind, ordinal, timestamp, timestamp,
|
|
724
|
+
result.ok ? 'accepted' : result.rejected ? 'rejected' : 'failed',
|
|
725
|
+
String(result.code ?? '').slice(0, 80),
|
|
726
|
+
String(result.message ?? '').slice(0, 500),
|
|
727
|
+
Math.max(0, Number(result.durationMs ?? 0)),
|
|
728
|
+
);
|
|
729
|
+
return ordinal;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function terminalRetention(timestamp) {
|
|
733
|
+
return plusMilliseconds(timestamp, RETENTION_MS);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function recoverStaleLifecycleClaims(database, timestamp) {
|
|
737
|
+
const cutoff = plusMilliseconds(timestamp, -CLAIM_RECOVERY_MS);
|
|
738
|
+
database.exec('BEGIN IMMEDIATE;');
|
|
739
|
+
try {
|
|
740
|
+
const stale = database.prepare(`
|
|
741
|
+
SELECT * FROM lifecycle_deliveries
|
|
742
|
+
WHERE status = 'delivering' AND updated_at <= ?
|
|
743
|
+
ORDER BY updated_at, created_at
|
|
744
|
+
`).all(cutoff);
|
|
745
|
+
let recovered = 0;
|
|
746
|
+
for (const delivery of stale) {
|
|
747
|
+
const current = database.prepare(`
|
|
748
|
+
SELECT * FROM lifecycle_deliveries
|
|
749
|
+
WHERE id = ? AND status = 'delivering' AND updated_at <= ?
|
|
750
|
+
`).get(delivery.id, cutoff);
|
|
751
|
+
if (!current) continue;
|
|
752
|
+
const kind = current.manual_pending ? 'manual' : 'automatic';
|
|
753
|
+
const outcome = {
|
|
754
|
+
ok: false,
|
|
755
|
+
code: 'worker-interrupted',
|
|
756
|
+
message: 'The previous lifecycle delivery worker stopped before completing the attempt.',
|
|
757
|
+
durationMs: 0,
|
|
758
|
+
};
|
|
759
|
+
recordAttempt(database, current, kind, outcome, timestamp);
|
|
760
|
+
const automaticCount = current.automatic_attempt_count + (kind === 'automatic' ? 1 : 0);
|
|
761
|
+
if (kind === 'automatic' && automaticCount < AUTOMATIC_ATTEMPTS) {
|
|
762
|
+
database.prepare(`
|
|
763
|
+
UPDATE lifecycle_deliveries SET status = 'retrying', automatic_attempt_count = ?,
|
|
764
|
+
manual_pending = 0, next_attempt_at = ?, last_code = ?, last_message = ?,
|
|
765
|
+
updated_at = ? WHERE id = ?
|
|
766
|
+
`).run(automaticCount, timestamp, outcome.code, outcome.message, timestamp, current.id);
|
|
767
|
+
} else {
|
|
768
|
+
database.prepare(`
|
|
769
|
+
UPDATE lifecycle_deliveries SET status = 'exhausted', automatic_attempt_count = ?,
|
|
770
|
+
manual_pending = 0, next_attempt_at = NULL, last_code = ?, last_message = ?,
|
|
771
|
+
updated_at = ? WHERE id = ?
|
|
772
|
+
`).run(automaticCount, outcome.code, outcome.message, timestamp, current.id);
|
|
773
|
+
}
|
|
774
|
+
recovered += 1;
|
|
775
|
+
}
|
|
776
|
+
database.exec('COMMIT;');
|
|
777
|
+
return recovered;
|
|
778
|
+
} catch (error) {
|
|
779
|
+
database.exec('ROLLBACK;');
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function cleanupLifecycleHookRecords(projectRoot, options = {}) {
|
|
785
|
+
const database = openRuntimeDatabase(projectRoot);
|
|
786
|
+
const timestamp = isoNow(options);
|
|
787
|
+
try {
|
|
788
|
+
const deliveries = database.prepare(`
|
|
789
|
+
SELECT id FROM lifecycle_deliveries
|
|
790
|
+
WHERE retention_after IS NOT NULL AND retention_after <= ?
|
|
791
|
+
`).all(timestamp);
|
|
792
|
+
for (const delivery of deliveries) database.prepare('DELETE FROM lifecycle_deliveries WHERE id = ?').run(delivery.id);
|
|
793
|
+
const orphanCutoff = new Date(new Date(timestamp).getTime() - RETENTION_MS).toISOString();
|
|
794
|
+
database.prepare(`
|
|
795
|
+
DELETE FROM lifecycle_events
|
|
796
|
+
WHERE occurred_at <= ? AND NOT EXISTS (
|
|
797
|
+
SELECT 1 FROM lifecycle_deliveries d WHERE d.event_id = lifecycle_events.id
|
|
798
|
+
)
|
|
799
|
+
`).run(orphanCutoff);
|
|
800
|
+
return { schema: 'ewai.lifecycle-cleanup/v1', removedDeliveries: deliveries.length };
|
|
801
|
+
} finally {
|
|
802
|
+
database.close();
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export async function dispatchEligibleLifecycleDeliveries(projectRoot, options = {}) {
|
|
807
|
+
cleanupLifecycleHookRecords(projectRoot, options);
|
|
808
|
+
const database = openRuntimeDatabase(projectRoot);
|
|
809
|
+
const timestamp = isoNow(options);
|
|
810
|
+
const limit = Math.max(1, Math.min(50, Number(options.limit ?? 10)));
|
|
811
|
+
let recoveredClaims = 0;
|
|
812
|
+
let candidates;
|
|
813
|
+
try {
|
|
814
|
+
recoveredClaims = recoverStaleLifecycleClaims(database, timestamp);
|
|
815
|
+
candidates = database.prepare(`
|
|
816
|
+
SELECT d.*, e.name AS event_name, e.stream_id, e.stream_sequence,
|
|
817
|
+
e.schema_version, e.occurred_at, e.source_key, e.source_revision,
|
|
818
|
+
e.project_json, e.scope_json, e.facts_json, e.evidence_json, e.personas_json,
|
|
819
|
+
s.enabled AS subscription_enabled
|
|
820
|
+
FROM lifecycle_deliveries d
|
|
821
|
+
JOIN lifecycle_events e ON e.id = d.event_id
|
|
822
|
+
JOIN lifecycle_subscriptions s ON s.id = d.subscription_id
|
|
823
|
+
WHERE d.status IN ('queued', 'retrying')
|
|
824
|
+
AND d.next_attempt_at IS NOT NULL
|
|
825
|
+
AND d.next_attempt_at <= ?
|
|
826
|
+
AND NOT EXISTS (
|
|
827
|
+
SELECT 1
|
|
828
|
+
FROM lifecycle_deliveries earlier
|
|
829
|
+
JOIN lifecycle_events earlier_event ON earlier_event.id = earlier.event_id
|
|
830
|
+
WHERE earlier.subscription_id = d.subscription_id
|
|
831
|
+
AND earlier_event.stream_id = e.stream_id
|
|
832
|
+
AND earlier_event.stream_sequence < e.stream_sequence
|
|
833
|
+
AND earlier.status IN ('queued', 'retrying', 'delivering')
|
|
834
|
+
)
|
|
835
|
+
ORDER BY e.occurred_at, e.stream_sequence, d.created_at
|
|
836
|
+
LIMIT ?
|
|
837
|
+
`).all(timestamp, limit);
|
|
838
|
+
} finally {
|
|
839
|
+
database.close();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const results = [];
|
|
843
|
+
for (const candidate of candidates) {
|
|
844
|
+
const claimDatabase = openRuntimeDatabase(projectRoot);
|
|
845
|
+
let claimed = false;
|
|
846
|
+
try {
|
|
847
|
+
const update = claimDatabase.prepare(`
|
|
848
|
+
UPDATE lifecycle_deliveries SET status = 'delivering', updated_at = ?
|
|
849
|
+
WHERE id = ? AND status IN ('queued', 'retrying')
|
|
850
|
+
`).run(timestamp, candidate.id);
|
|
851
|
+
claimed = update.changes === 1;
|
|
852
|
+
} finally {
|
|
853
|
+
claimDatabase.close();
|
|
854
|
+
}
|
|
855
|
+
if (!claimed) continue;
|
|
856
|
+
|
|
857
|
+
const snapshot = parseJson(candidate.handler_snapshot_json, {});
|
|
858
|
+
let outcome;
|
|
859
|
+
if (!candidate.subscription_enabled) {
|
|
860
|
+
outcome = { ok: false, disabled: true, code: 'subscription-disabled', message: 'The lifecycle subscription is disabled.', durationMs: 0 };
|
|
861
|
+
} else {
|
|
862
|
+
try {
|
|
863
|
+
verifyHandlerSnapshot(snapshot);
|
|
864
|
+
const event = eventFromRow({
|
|
865
|
+
id: candidate.event_id,
|
|
866
|
+
name: candidate.event_name,
|
|
867
|
+
occurred_at: candidate.occurred_at,
|
|
868
|
+
source_key: candidate.source_key,
|
|
869
|
+
source_revision: candidate.source_revision,
|
|
870
|
+
project_json: candidate.project_json,
|
|
871
|
+
scope_json: candidate.scope_json,
|
|
872
|
+
facts_json: candidate.facts_json,
|
|
873
|
+
evidence_json: candidate.evidence_json,
|
|
874
|
+
personas_json: candidate.personas_json,
|
|
875
|
+
stream_id: candidate.stream_id,
|
|
876
|
+
stream_sequence: candidate.stream_sequence,
|
|
877
|
+
}, candidate.idempotency_key);
|
|
878
|
+
outcome = options.invoke
|
|
879
|
+
? await options.invoke({ snapshot, event, delivery: candidate })
|
|
880
|
+
: await invokeLifecycleHandler(snapshot, event, options);
|
|
881
|
+
} catch {
|
|
882
|
+
outcome = { ok: false, incompatible: true, code: 'handler-incompatible', message: 'The registered lifecycle handler package no longer matches its trusted identity.', durationMs: 0 };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const resultTime = isoNow(options);
|
|
887
|
+
const resultDatabase = openRuntimeDatabase(projectRoot);
|
|
888
|
+
try {
|
|
889
|
+
const current = resultDatabase.prepare('SELECT * FROM lifecycle_deliveries WHERE id = ?').get(candidate.id);
|
|
890
|
+
if (!current) continue;
|
|
891
|
+
const kind = current.manual_pending ? 'manual' : 'automatic';
|
|
892
|
+
recordAttempt(resultDatabase, current, kind, outcome, resultTime);
|
|
893
|
+
const automaticCount = current.automatic_attempt_count + (kind === 'automatic' ? 1 : 0);
|
|
894
|
+
if (outcome.disabled) {
|
|
895
|
+
resultDatabase.prepare(`
|
|
896
|
+
UPDATE lifecycle_deliveries SET status = 'disabled', manual_pending = 0,
|
|
897
|
+
next_attempt_at = NULL, last_code = ?, last_message = ?, updated_at = ?,
|
|
898
|
+
disabled_at = ?, resolved_at = ?, retention_after = ? WHERE id = ?
|
|
899
|
+
`).run(outcome.code, outcome.message, resultTime, resultTime, resultTime, terminalRetention(resultTime), current.id);
|
|
900
|
+
} else if (outcome.ok) {
|
|
901
|
+
resultDatabase.prepare(`
|
|
902
|
+
UPDATE lifecycle_deliveries SET status = 'succeeded', automatic_attempt_count = ?,
|
|
903
|
+
manual_pending = 0, next_attempt_at = NULL, last_code = ?, last_message = ?,
|
|
904
|
+
updated_at = ?, resolved_at = ?, retention_after = ? WHERE id = ?
|
|
905
|
+
`).run(automaticCount, outcome.code ?? 'accepted', outcome.message ?? 'Event received.', resultTime, resultTime, terminalRetention(resultTime), current.id);
|
|
906
|
+
} else if (outcome.rejected || outcome.incompatible || kind === 'manual') {
|
|
907
|
+
const status = outcome.rejected ? 'rejected' : outcome.incompatible ? 'incompatible' : 'exhausted';
|
|
908
|
+
resultDatabase.prepare(`
|
|
909
|
+
UPDATE lifecycle_deliveries SET status = ?, automatic_attempt_count = ?,
|
|
910
|
+
manual_pending = 0, next_attempt_at = NULL, last_code = ?, last_message = ?,
|
|
911
|
+
updated_at = ? WHERE id = ?
|
|
912
|
+
`).run(status, automaticCount, outcome.code ?? 'handler-failed', outcome.message ?? 'Lifecycle delivery failed.', resultTime, current.id);
|
|
913
|
+
} else if (automaticCount < AUTOMATIC_ATTEMPTS) {
|
|
914
|
+
const next = plusMilliseconds(resultTime, RETRY_DELAYS_MS[automaticCount - 1]);
|
|
915
|
+
resultDatabase.prepare(`
|
|
916
|
+
UPDATE lifecycle_deliveries SET status = 'retrying', automatic_attempt_count = ?,
|
|
917
|
+
manual_pending = 0, next_attempt_at = ?, last_code = ?, last_message = ?,
|
|
918
|
+
updated_at = ? WHERE id = ?
|
|
919
|
+
`).run(automaticCount, next, outcome.code ?? 'handler-failed', outcome.message ?? 'Lifecycle delivery failed.', resultTime, current.id);
|
|
920
|
+
} else {
|
|
921
|
+
resultDatabase.prepare(`
|
|
922
|
+
UPDATE lifecycle_deliveries SET status = 'exhausted', automatic_attempt_count = ?,
|
|
923
|
+
manual_pending = 0, next_attempt_at = NULL, last_code = ?, last_message = ?,
|
|
924
|
+
updated_at = ? WHERE id = ?
|
|
925
|
+
`).run(automaticCount, outcome.code ?? 'handler-failed', outcome.message ?? 'Lifecycle delivery failed.', resultTime, current.id);
|
|
926
|
+
}
|
|
927
|
+
results.push({ id: current.id, status: resultDatabase.prepare('SELECT status FROM lifecycle_deliveries WHERE id = ?').get(current.id).status });
|
|
928
|
+
} finally {
|
|
929
|
+
resultDatabase.close();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return { schema: 'ewai.lifecycle-dispatch/v1', recoveredClaims, attempted: results.length, deliveries: results };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export function retryLifecycleDelivery(projectRoot, deliveryId, input = {}) {
|
|
936
|
+
if (input.confirmed !== true) throw new Error('Lifecycle delivery retry requires explicit confirmation');
|
|
937
|
+
const database = openRuntimeDatabase(projectRoot);
|
|
938
|
+
const timestamp = isoNow(input);
|
|
939
|
+
try {
|
|
940
|
+
const delivery = database.prepare('SELECT * FROM lifecycle_deliveries WHERE id = ?').get(deliveryId);
|
|
941
|
+
if (!delivery) throw new Error(`Lifecycle delivery not found: ${deliveryId}`);
|
|
942
|
+
if (!['exhausted', 'rejected', 'incompatible'].includes(delivery.status)) {
|
|
943
|
+
throw new Error(`Lifecycle delivery is not eligible for manual retry: ${delivery.status}`);
|
|
944
|
+
}
|
|
945
|
+
const subscription = database.prepare('SELECT enabled FROM lifecycle_subscriptions WHERE id = ?').get(delivery.subscription_id);
|
|
946
|
+
if (!subscription?.enabled) throw new Error('Lifecycle delivery cannot retry while its subscription is disabled');
|
|
947
|
+
database.prepare(`
|
|
948
|
+
UPDATE lifecycle_deliveries SET status = 'queued', manual_pending = 1,
|
|
949
|
+
next_attempt_at = ?, updated_at = ?, last_code = '', last_message = ''
|
|
950
|
+
WHERE id = ?
|
|
951
|
+
`).run(timestamp, timestamp, deliveryId);
|
|
952
|
+
return { schema: 'ewai.lifecycle-retry/v1', id: deliveryId, status: 'queued', eventId: delivery.event_id, idempotencyKey: delivery.idempotency_key };
|
|
953
|
+
} finally {
|
|
954
|
+
database.close();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export function disableLifecycleSubscription(projectRoot, subscriptionId, input = {}) {
|
|
959
|
+
if (input.confirmed !== true) throw new Error('Lifecycle subscription disable requires explicit confirmation');
|
|
960
|
+
const database = openRuntimeDatabase(projectRoot);
|
|
961
|
+
const timestamp = isoNow(input);
|
|
962
|
+
try {
|
|
963
|
+
const subscription = database.prepare('SELECT * FROM lifecycle_subscriptions WHERE id = ?').get(subscriptionId);
|
|
964
|
+
if (!subscription) throw new Error(`Lifecycle subscription not found: ${subscriptionId}`);
|
|
965
|
+
database.prepare(`
|
|
966
|
+
UPDATE lifecycle_subscriptions SET enabled = 0, disabled_at = ?, updated_at = ? WHERE id = ?
|
|
967
|
+
`).run(timestamp, timestamp, subscriptionId);
|
|
968
|
+
database.prepare(`
|
|
969
|
+
UPDATE lifecycle_deliveries SET status = 'disabled', manual_pending = 0,
|
|
970
|
+
next_attempt_at = NULL, last_code = 'subscription-disabled',
|
|
971
|
+
last_message = 'The lifecycle subscription was disabled.', updated_at = ?,
|
|
972
|
+
disabled_at = ?, resolved_at = COALESCE(resolved_at, ?),
|
|
973
|
+
retention_after = COALESCE(retention_after, ?)
|
|
974
|
+
WHERE subscription_id = ? AND status != 'succeeded' AND status != 'delivering'
|
|
975
|
+
`).run(timestamp, timestamp, timestamp, terminalRetention(timestamp), subscriptionId);
|
|
976
|
+
return { schema: 'ewai.lifecycle-disable/v1', id: subscriptionId, handlerId: subscription.handler_id, enabled: false, disabledAt: timestamp };
|
|
977
|
+
} finally {
|
|
978
|
+
database.close();
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function safeWorkspaceHandler(row) {
|
|
983
|
+
const manifest = parseJson(row.manifest_json, {});
|
|
984
|
+
return {
|
|
985
|
+
id: row.id,
|
|
986
|
+
name: row.name,
|
|
987
|
+
publisher: { id: row.publisher_id, name: row.publisher_name },
|
|
988
|
+
version: row.version,
|
|
989
|
+
protocolVersion: row.protocol_version,
|
|
990
|
+
eventSchemaVersion: row.event_schema_version,
|
|
991
|
+
events: manifest.events ?? [],
|
|
992
|
+
packageDigest: `sha256:${row.package_digest}`,
|
|
993
|
+
digestStatus: 'verified-at-registration',
|
|
994
|
+
registeredAt: row.registered_at,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
export function readLifecycleHookWorkspace(projectRoot, filters = {}) {
|
|
999
|
+
const database = openRuntimeDatabase(projectRoot);
|
|
1000
|
+
try {
|
|
1001
|
+
const handlers = database.prepare('SELECT * FROM lifecycle_handlers ORDER BY name, id').all().map(safeWorkspaceHandler);
|
|
1002
|
+
const subscriptions = database.prepare('SELECT * FROM lifecycle_subscriptions ORDER BY created_at DESC').all().map((row) => ({
|
|
1003
|
+
id: row.id,
|
|
1004
|
+
handlerId: row.handler_id,
|
|
1005
|
+
patterns: parseJson(row.patterns_json, []),
|
|
1006
|
+
enabled: Boolean(row.enabled),
|
|
1007
|
+
createdAt: row.created_at,
|
|
1008
|
+
disabledAt: row.disabled_at,
|
|
1009
|
+
}));
|
|
1010
|
+
const eventRows = database.prepare('SELECT * FROM lifecycle_events ORDER BY occurred_at DESC, stream_sequence DESC LIMIT 200').all();
|
|
1011
|
+
const events = eventRows.map((row) => eventFromRow(row));
|
|
1012
|
+
let deliveries = database.prepare(`
|
|
1013
|
+
SELECT d.*, e.name AS event_name, e.occurred_at, e.stream_id, e.stream_sequence,
|
|
1014
|
+
s.handler_id, h.name AS handler_name
|
|
1015
|
+
FROM lifecycle_deliveries d
|
|
1016
|
+
JOIN lifecycle_events e ON e.id = d.event_id
|
|
1017
|
+
JOIN lifecycle_subscriptions s ON s.id = d.subscription_id
|
|
1018
|
+
JOIN lifecycle_handlers h ON h.id = s.handler_id
|
|
1019
|
+
ORDER BY e.occurred_at DESC, e.stream_sequence DESC
|
|
1020
|
+
LIMIT 300
|
|
1021
|
+
`).all();
|
|
1022
|
+
if (filters.status) deliveries = deliveries.filter((row) => row.status === filters.status);
|
|
1023
|
+
if (filters.event) deliveries = deliveries.filter((row) => row.event_name === filters.event);
|
|
1024
|
+
if (filters.handler) deliveries = deliveries.filter((row) => row.handler_id === filters.handler);
|
|
1025
|
+
const deliveryIds = new Set(deliveries.map((row) => row.id));
|
|
1026
|
+
const attempts = database.prepare('SELECT * FROM lifecycle_attempts ORDER BY completed_at DESC, ordinal DESC LIMIT 500').all()
|
|
1027
|
+
.filter((row) => deliveryIds.has(row.delivery_id))
|
|
1028
|
+
.map((row) => ({
|
|
1029
|
+
id: row.id,
|
|
1030
|
+
deliveryId: row.delivery_id,
|
|
1031
|
+
kind: row.kind,
|
|
1032
|
+
ordinal: row.ordinal,
|
|
1033
|
+
startedAt: row.started_at,
|
|
1034
|
+
completedAt: row.completed_at,
|
|
1035
|
+
status: row.status,
|
|
1036
|
+
code: row.code,
|
|
1037
|
+
message: row.message,
|
|
1038
|
+
durationMs: row.duration_ms,
|
|
1039
|
+
}));
|
|
1040
|
+
const safeDeliveries = deliveries.map((row) => {
|
|
1041
|
+
const snapshot = parseJson(row.handler_snapshot_json, {});
|
|
1042
|
+
return ({
|
|
1043
|
+
id: row.id,
|
|
1044
|
+
eventId: row.event_id,
|
|
1045
|
+
eventName: row.event_name,
|
|
1046
|
+
occurredAt: row.occurred_at,
|
|
1047
|
+
subscriptionId: row.subscription_id,
|
|
1048
|
+
handlerId: row.handler_id,
|
|
1049
|
+
handlerName: row.handler_name,
|
|
1050
|
+
handlerVersion: snapshot.version ?? '',
|
|
1051
|
+
packageDigest: snapshot.packageDigest ? `sha256:${snapshot.packageDigest}` : '',
|
|
1052
|
+
idempotencyKey: row.idempotency_key,
|
|
1053
|
+
stream: { id: row.stream_id, sequence: row.stream_sequence },
|
|
1054
|
+
status: row.status,
|
|
1055
|
+
automaticAttemptCount: row.automatic_attempt_count,
|
|
1056
|
+
manualPending: Boolean(row.manual_pending),
|
|
1057
|
+
nextAttemptAt: row.next_attempt_at,
|
|
1058
|
+
lastCode: row.last_code,
|
|
1059
|
+
lastMessage: row.last_message,
|
|
1060
|
+
createdAt: row.created_at,
|
|
1061
|
+
updatedAt: row.updated_at,
|
|
1062
|
+
resolvedAt: row.resolved_at,
|
|
1063
|
+
disabledAt: row.disabled_at,
|
|
1064
|
+
retentionAfter: row.retention_after,
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
const counts = Object.fromEntries(['queued', 'retrying', 'delivering', 'succeeded', 'exhausted', 'rejected', 'incompatible', 'disabled']
|
|
1068
|
+
.map((status) => [status, safeDeliveries.filter((delivery) => delivery.status === status).length]));
|
|
1069
|
+
return {
|
|
1070
|
+
schema: 'ewai.lifecycle-hook-workspace/v1',
|
|
1071
|
+
catalogue: LIFECYCLE_EVENT_CATALOGUE,
|
|
1072
|
+
summary: {
|
|
1073
|
+
handlers: handlers.length,
|
|
1074
|
+
subscriptions: subscriptions.filter((subscription) => subscription.enabled).length,
|
|
1075
|
+
events: events.length,
|
|
1076
|
+
deliveries: safeDeliveries.length,
|
|
1077
|
+
...counts,
|
|
1078
|
+
},
|
|
1079
|
+
handlers,
|
|
1080
|
+
subscriptions,
|
|
1081
|
+
events,
|
|
1082
|
+
deliveries: safeDeliveries,
|
|
1083
|
+
attempts,
|
|
1084
|
+
};
|
|
1085
|
+
} finally {
|
|
1086
|
+
database.close();
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function jsonFiles(root, found = []) {
|
|
1091
|
+
if (!existsSync(root)) return found;
|
|
1092
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
1093
|
+
const path = resolve(root, entry.name);
|
|
1094
|
+
if (entry.isDirectory()) jsonFiles(path, found);
|
|
1095
|
+
else if (entry.isFile() && entry.name.endsWith('.json')) found.push(path);
|
|
1096
|
+
}
|
|
1097
|
+
return found;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function deliveryReleaseReady(state, projectRoot) {
|
|
1101
|
+
const delivery = (state.phases ?? []).find((phase) => phase.id === 'delivery');
|
|
1102
|
+
const canonicalReady = delivery?.status === 'completed'
|
|
1103
|
+
&& Boolean(state.approvals?.build)
|
|
1104
|
+
&& (state.humanGates ?? []).filter((gate) => gate.id).every((gate) => gate.status === 'approved');
|
|
1105
|
+
return canonicalReady && assessSecurityReadiness(projectRoot, {
|
|
1106
|
+
policySnapshot: state.approvals?.build?.securityPolicy,
|
|
1107
|
+
}).ready;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function deliveryPersonaAttachments(projectRoot, state) {
|
|
1111
|
+
if (Array.isArray(state.intent?.personas)) return state.intent.personas;
|
|
1112
|
+
const intentPath = String(state.intent?.path ?? '');
|
|
1113
|
+
if (!intentPath) return [];
|
|
1114
|
+
const sidecar = resolve(projectRoot, intentPath.replace(/\.md$/i, '.json'));
|
|
1115
|
+
if (!isWithin(resolve(projectRoot), sidecar) || !existsSync(sidecar)) return [];
|
|
1116
|
+
const intent = parseJson(readFileSync(sidecar, 'utf8'), {});
|
|
1117
|
+
return Array.isArray(intent.personas) ? intent.personas : [];
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
export function reconcileCanonicalLifecycleEvents(projectRoot, options = {}) {
|
|
1121
|
+
const { config, paths } = loadProjectConfig(projectRoot);
|
|
1122
|
+
const publications = [];
|
|
1123
|
+
const discovery = config.project?.discovery;
|
|
1124
|
+
if (discovery?.status === 'complete' && discovery.completed_at) {
|
|
1125
|
+
publications.push(publishLifecycleEventSafely(projectRoot, 'ewai.project.discovery.completed', {
|
|
1126
|
+
sourceKey: `project.discovery.completed:${discovery.completed_at}`,
|
|
1127
|
+
occurredAt: discovery.completed_at,
|
|
1128
|
+
sourceRevision: discovery.completed_at,
|
|
1129
|
+
facts: { status: 'complete' },
|
|
1130
|
+
evidence: discovery.evidence ? [discovery.evidence] : [],
|
|
1131
|
+
streamId: 'project',
|
|
1132
|
+
now: options.now,
|
|
1133
|
+
}));
|
|
1134
|
+
}
|
|
1135
|
+
for (const path of jsonFiles(resolve(paths.specsRoot, '2.Purpose/intents'))) {
|
|
1136
|
+
const state = parseJson(readFileSync(path, 'utf8'), {});
|
|
1137
|
+
if (state.schema !== 'ewai.intent-state/v1' || !state.slug || !state.domain) continue;
|
|
1138
|
+
publications.push(publishLifecycleEventSafely(projectRoot, 'ewai.intent.created', {
|
|
1139
|
+
sourceKey: `intent.created:${state.domain}/${state.slug}`,
|
|
1140
|
+
occurredAt: state.updatedAt,
|
|
1141
|
+
sourceRevision: state.updatedAt,
|
|
1142
|
+
scope: { intent: `${state.domain}/${state.slug}` },
|
|
1143
|
+
facts: { status: state.status ?? 'draft', domain: state.domain, slug: state.slug },
|
|
1144
|
+
evidence: state.markdownPath ? [state.markdownPath] : [],
|
|
1145
|
+
personas: state.personas ?? [],
|
|
1146
|
+
streamId: `intent:${state.domain}/${state.slug}`,
|
|
1147
|
+
now: options.now,
|
|
1148
|
+
}));
|
|
1149
|
+
}
|
|
1150
|
+
const buildRoot = resolve(paths.specsRoot, '6.Build');
|
|
1151
|
+
if (existsSync(buildRoot)) {
|
|
1152
|
+
for (const entry of readdirSync(buildRoot, { withFileTypes: true })) {
|
|
1153
|
+
if (!entry.isDirectory()) continue;
|
|
1154
|
+
const statePath = resolve(buildRoot, entry.name, 'delivery-state.json');
|
|
1155
|
+
if (!existsSync(statePath)) continue;
|
|
1156
|
+
const state = parseJson(readFileSync(statePath, 'utf8'), {});
|
|
1157
|
+
if (state.schema !== 'ewai.delivery-state/v1' || !state.slug) continue;
|
|
1158
|
+
const scope = { intent: state.intent?.id ?? state.slug, delivery: state.slug };
|
|
1159
|
+
const personas = deliveryPersonaAttachments(paths.projectRoot, state);
|
|
1160
|
+
for (const phase of state.phases ?? []) {
|
|
1161
|
+
if (phase.startedAt) publications.push(publishLifecycleEventSafely(projectRoot, 'ewai.delivery.phase.entered', {
|
|
1162
|
+
sourceKey: `delivery:${state.slug}:phase:${phase.id}:entered:${phase.startedAt}`,
|
|
1163
|
+
occurredAt: phase.startedAt,
|
|
1164
|
+
sourceRevision: state.updatedAt,
|
|
1165
|
+
scope: { ...scope, phase: phase.id },
|
|
1166
|
+
facts: { status: 'running', phase: phase.id },
|
|
1167
|
+
personas,
|
|
1168
|
+
streamId: `delivery:${state.slug}`,
|
|
1169
|
+
now: options.now,
|
|
1170
|
+
}));
|
|
1171
|
+
if (phase.completedAt && phase.status === 'completed') publications.push(publishLifecycleEventSafely(projectRoot, 'ewai.delivery.phase.completed', {
|
|
1172
|
+
sourceKey: `delivery:${state.slug}:phase:${phase.id}:completed:${phase.completedAt}`,
|
|
1173
|
+
occurredAt: phase.completedAt,
|
|
1174
|
+
sourceRevision: state.updatedAt,
|
|
1175
|
+
scope: { ...scope, phase: phase.id },
|
|
1176
|
+
facts: { status: 'completed', phase: phase.id },
|
|
1177
|
+
evidence: phase.gatePath ? [phase.gatePath] : [],
|
|
1178
|
+
personas,
|
|
1179
|
+
streamId: `delivery:${state.slug}`,
|
|
1180
|
+
now: options.now,
|
|
1181
|
+
}));
|
|
1182
|
+
}
|
|
1183
|
+
if (state.approvals?.build?.approvedAt) publications.push(publishLifecycleEventSafely(projectRoot, 'ewai.delivery.build.approved', {
|
|
1184
|
+
sourceKey: `delivery:${state.slug}:build.approved:${state.approvals.build.approvedAt}`,
|
|
1185
|
+
occurredAt: state.approvals.build.approvedAt,
|
|
1186
|
+
sourceRevision: state.updatedAt,
|
|
1187
|
+
scope,
|
|
1188
|
+
facts: { status: 'approved', decision: 'approved' },
|
|
1189
|
+
evidence: state.approvals.build.path ? [state.approvals.build.path] : [],
|
|
1190
|
+
personas,
|
|
1191
|
+
streamId: `delivery:${state.slug}`,
|
|
1192
|
+
now: options.now,
|
|
1193
|
+
}));
|
|
1194
|
+
const manual = (state.humanGates ?? []).find((gate) => gate.id === 'manual-qa');
|
|
1195
|
+
if (manual?.status === 'approved' && manual.approvedAt) publications.push(publishLifecycleEventSafely(projectRoot, 'ewai.delivery.manual-qa.approved', {
|
|
1196
|
+
sourceKey: `delivery:${state.slug}:manual-qa.approved:${manual.approvedAt}`,
|
|
1197
|
+
occurredAt: manual.approvedAt,
|
|
1198
|
+
sourceRevision: state.updatedAt,
|
|
1199
|
+
scope,
|
|
1200
|
+
facts: { status: 'approved', decision: 'approved' },
|
|
1201
|
+
evidence: manual.evidencePath ? [manual.evidencePath] : [],
|
|
1202
|
+
personas,
|
|
1203
|
+
streamId: `delivery:${state.slug}`,
|
|
1204
|
+
now: options.now,
|
|
1205
|
+
}));
|
|
1206
|
+
const delivery = (state.phases ?? []).find((phase) => phase.id === 'delivery');
|
|
1207
|
+
if (delivery?.status === 'completed' && delivery.completedAt) publications.push(publishLifecycleEventSafely(projectRoot, 'ewai.delivery.completed', {
|
|
1208
|
+
sourceKey: `delivery:${state.slug}:completed:${delivery.completedAt}`,
|
|
1209
|
+
occurredAt: delivery.completedAt,
|
|
1210
|
+
sourceRevision: state.updatedAt,
|
|
1211
|
+
scope,
|
|
1212
|
+
facts: { status: 'completed' },
|
|
1213
|
+
evidence: delivery.gatePath ? [delivery.gatePath] : [],
|
|
1214
|
+
personas,
|
|
1215
|
+
streamId: `delivery:${state.slug}`,
|
|
1216
|
+
now: options.now,
|
|
1217
|
+
}));
|
|
1218
|
+
if (deliveryReleaseReady(state, paths.projectRoot)) {
|
|
1219
|
+
const readyAt = manual?.approvedAt ?? state.updatedAt;
|
|
1220
|
+
publications.push(publishLifecycleEventSafely(projectRoot, 'ewai.delivery.release-ready', {
|
|
1221
|
+
sourceKey: `delivery:${state.slug}:release-ready:${readyAt}`,
|
|
1222
|
+
occurredAt: readyAt,
|
|
1223
|
+
sourceRevision: state.updatedAt,
|
|
1224
|
+
scope,
|
|
1225
|
+
facts: { status: 'ready' },
|
|
1226
|
+
personas,
|
|
1227
|
+
streamId: `delivery:${state.slug}`,
|
|
1228
|
+
now: options.now,
|
|
1229
|
+
}));
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
schema: 'ewai.lifecycle-reconciliation/v1',
|
|
1235
|
+
observed: publications.length,
|
|
1236
|
+
created: publications.filter((publication) => publication.created).length,
|
|
1237
|
+
failed: publications.filter((publication) => publication.status === 'publication-failed').length,
|
|
1238
|
+
};
|
|
1239
|
+
}
|