@a5c-ai/krate 5.0.1-staging.00fa5317c
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/Dockerfile +31 -0
- package/README.md +183 -0
- package/bin/krate-demo.mjs +23 -0
- package/bin/krate-server.mjs +14 -0
- package/dist/krate-controller-ui.json +3205 -0
- package/dist/krate-lifecycle.json +201 -0
- package/dist/krate-runtime-snapshot.json +3125 -0
- package/dist/krate-summary.json +724 -0
- package/docs/README.md +61 -0
- package/docs/agents/README.md +83 -0
- package/docs/agents/acceptance-test-matrix.md +193 -0
- package/docs/agents/agent-mux-adapter-contract.md +167 -0
- package/docs/agents/agent-mux-source-map.md +310 -0
- package/docs/agents/agent-run-memory-import-spec.md +256 -0
- package/docs/agents/agent-stack-management-spec.md +421 -0
- package/docs/agents/api-contract-spec.md +309 -0
- package/docs/agents/artifacts-writeback-spec.md +145 -0
- package/docs/agents/chart-packaging-spec.md +128 -0
- package/docs/agents/ci-orchestration-spec.md +140 -0
- package/docs/agents/context-assembly-spec.md +219 -0
- package/docs/agents/controller-reconciliation-spec.md +255 -0
- package/docs/agents/crd-schema-spec.md +315 -0
- package/docs/agents/decision-log-open-questions.md +169 -0
- package/docs/agents/developer-implementation-checklist.md +329 -0
- package/docs/agents/dispatching-design.md +262 -0
- package/docs/agents/gaps-agent-mux-to-krate-crds.md +298 -0
- package/docs/agents/glossary.md +66 -0
- package/docs/agents/implementation-blueprint.md +324 -0
- package/docs/agents/implementation-rollout-slices.md +251 -0
- package/docs/agents/memory-context-integration-spec.md +194 -0
- package/docs/agents/memory-ontology-schema-spec.md +253 -0
- package/docs/agents/memory-operations-runbook.md +121 -0
- package/docs/agents/mvp-vertical-slice-spec.md +146 -0
- package/docs/agents/observability-audit-spec.md +265 -0
- package/docs/agents/operator-runbook.md +174 -0
- package/docs/agents/org-memory-api-payload-examples.md +333 -0
- package/docs/agents/org-memory-controller-sequence-spec.md +181 -0
- package/docs/agents/org-memory-e2e-fixture-plan.md +161 -0
- package/docs/agents/org-memory-ui-implementation-map.md +114 -0
- package/docs/agents/org-memory-vertical-slice-spec.md +168 -0
- package/docs/agents/org-resource-model-delta-spec.md +111 -0
- package/docs/agents/org-route-resource-model-spec.md +183 -0
- package/docs/agents/org-scoping-namespace-spec.md +114 -0
- package/docs/agents/rbac-secrets-management-spec.md +406 -0
- package/docs/agents/repository-page-integration-spec.md +255 -0
- package/docs/agents/resource-contract-examples.md +808 -0
- package/docs/agents/resource-relationship-map.md +190 -0
- package/docs/agents/security-threat-model.md +188 -0
- package/docs/agents/shared-memory-company-brain-spec.md +358 -0
- package/docs/agents/storage-migration-spec.md +168 -0
- package/docs/agents/subagent-orchestration-spec.md +152 -0
- package/docs/agents/system-overview.md +88 -0
- package/docs/agents/tools-mcp-skills-spec.md +189 -0
- package/docs/agents/traceability-matrix.md +79 -0
- package/docs/agents/ui-flow-spec.md +211 -0
- package/docs/agents/ui-ux-system-spec.md +426 -0
- package/docs/agents/workspace-lifecycle-spec.md +166 -0
- package/docs/architecture-spec.md +78 -0
- package/docs/components/control-plane.md +78 -0
- package/docs/components/data-plane.md +69 -0
- package/docs/components/hooks-events.md +67 -0
- package/docs/components/identity-rbac-policy.md +73 -0
- package/docs/components/kubevela-oam.md +70 -0
- package/docs/components/operations-publishing.md +81 -0
- package/docs/components/runners-ci.md +66 -0
- package/docs/components/web-ui.md +94 -0
- package/docs/external/README.md +47 -0
- package/docs/external/bidirectional-sync-design.md +134 -0
- package/docs/external/cicd-interface.md +64 -0
- package/docs/external/external-backend-controllers.md +170 -0
- package/docs/external/external-backend-crds.md +234 -0
- package/docs/external/external-backend-ui-spec.md +151 -0
- package/docs/external/external-backend-ux-flows.md +115 -0
- package/docs/external/external-object-mapping.md +125 -0
- package/docs/external/git-forge-interface.md +68 -0
- package/docs/external/github-integration-design.md +151 -0
- package/docs/external/issue-tracking-interface.md +66 -0
- package/docs/external/provider-capability-manifests.md +204 -0
- package/docs/external/provider-catalog.md +139 -0
- package/docs/external/provider-rollout-testing.md +78 -0
- package/docs/external/research-results.md +48 -0
- package/docs/external/security-auth-permissions.md +81 -0
- package/docs/external/sync-state-machines.md +108 -0
- package/docs/external/unified-external-backend-model.md +107 -0
- package/docs/external/user-facing-changes.md +67 -0
- package/docs/gaps.md +161 -0
- package/docs/install.md +94 -0
- package/docs/krate-design.md +334 -0
- package/docs/local-minikube.md +55 -0
- package/docs/ontology/README.md +32 -0
- package/docs/ontology/bounded-contexts.md +29 -0
- package/docs/ontology/events-and-hooks.md +32 -0
- package/docs/ontology/oam-kubevela.md +32 -0
- package/docs/ontology/operations-and-release.md +25 -0
- package/docs/ontology/personas-and-actors.md +32 -0
- package/docs/ontology/policies-and-invariants.md +33 -0
- package/docs/ontology/problem-space.md +30 -0
- package/docs/ontology/resource-contracts.md +40 -0
- package/docs/ontology/resource-taxonomy.md +42 -0
- package/docs/ontology/runners-and-ci.md +29 -0
- package/docs/ontology/solution-space.md +24 -0
- package/docs/ontology/storage-and-data-boundaries.md +29 -0
- package/docs/ontology/validation-matrix.md +24 -0
- package/docs/ontology/web-ui-excellent-flows.md +32 -0
- package/docs/ontology/workflows.md +39 -0
- package/docs/ontology/world.md +35 -0
- package/docs/openapi.yaml +1275 -0
- package/docs/product-requirements.md +62 -0
- package/docs/roadmap-mvp.md +87 -0
- package/docs/system-requirements.md +90 -0
- package/docs/tests/README.md +53 -0
- package/docs/tests/agent-qa-plan.md +63 -0
- package/docs/tests/browser-ui-tests.md +62 -0
- package/docs/tests/ci-quality-gates.md +48 -0
- package/docs/tests/coverage-model.md +64 -0
- package/docs/tests/e2e-scenario-tests.md +53 -0
- package/docs/tests/fixtures-test-data.md +63 -0
- package/docs/tests/observability-reliability-tests.md +54 -0
- package/docs/tests/product-test-matrix.md +145 -0
- package/docs/tests/qa-adoption-roadmap.md +130 -0
- package/docs/tests/qa-automation-plan.md +101 -0
- package/docs/tests/security-compliance-tests.md +57 -0
- package/docs/tests/test-framework-tools.md +88 -0
- package/docs/tests/test-suite-layout.md +121 -0
- package/docs/tests/unit-integration-tests.md +48 -0
- package/docs/todo-kyverno +714 -0
- package/docs/todos.md +4 -0
- package/docs/user-stories.md +78 -0
- package/examples/minikube-demo.yaml +190 -0
- package/examples/oam-application.yaml +23 -0
- package/examples/policy-kyverno-pr-title.yaml +18 -0
- package/package.json +63 -0
- package/scripts/build.mjs +29 -0
- package/scripts/setup-minikube.mjs +65 -0
- package/scripts/smoke.mjs +37 -0
- package/scripts/validate-doc-coverage.mjs +152 -0
- package/scripts/validate-package.mjs +93 -0
- package/scripts/validate-ui.mjs +278 -0
- package/src/agent-adapter-controller.js +169 -0
- package/src/agent-approval-controller.js +170 -0
- package/src/agent-context-bundles.js +242 -0
- package/src/agent-dispatch-controller.js +209 -0
- package/src/agent-gateway-config-controller.js +147 -0
- package/src/agent-memory-controller.js +357 -0
- package/src/agent-memory-import.js +327 -0
- package/src/agent-memory-query.js +292 -0
- package/src/agent-memory-repository-source-controller.js +255 -0
- package/src/agent-mux-client.js +280 -0
- package/src/agent-permission-review.js +250 -0
- package/src/agent-project-controller.js +117 -0
- package/src/agent-provider-config-controller.js +150 -0
- package/src/agent-secret-config-grant-controller.js +282 -0
- package/src/agent-session-transcript-controller.js +189 -0
- package/src/agent-stack-controller.js +347 -0
- package/src/agent-subagent-controller.js +160 -0
- package/src/agent-transport-binding-controller.js +121 -0
- package/src/agent-trigger-controller.js +381 -0
- package/src/agent-workspace-controller.js +702 -0
- package/src/agent-writeback-controller.js +302 -0
- package/src/api-controller.js +541 -0
- package/src/argocd-gitops.js +43 -0
- package/src/async-controller.js +207 -0
- package/src/audit-controller.js +191 -0
- package/src/auth.js +307 -0
- package/src/component-catalog.js +41 -0
- package/src/control-plane.js +136 -0
- package/src/controller-client.js +72 -0
- package/src/controller-ui.js +617 -0
- package/src/data-plane.js +179 -0
- package/src/event-bus.js +61 -0
- package/src/external/conflict-controller.js +225 -0
- package/src/external/github/auth.js +96 -0
- package/src/external/github/cicd.js +180 -0
- package/src/external/github/git-forge.js +240 -0
- package/src/external/github/index.js +144 -0
- package/src/external/github/issue-tracking.js +163 -0
- package/src/external/provider-adapter.js +161 -0
- package/src/external/provider-resource-factory.js +161 -0
- package/src/external/sync-controller.js +235 -0
- package/src/external/webhook-controller.js +144 -0
- package/src/external/write-controller.js +283 -0
- package/src/gitea-backend.js +131 -0
- package/src/gitea-service.js +173 -0
- package/src/handoff.js +98 -0
- package/src/hooks-events.js +63 -0
- package/src/http-server.js +377 -0
- package/src/identity-policy.js +86 -0
- package/src/index.js +57 -0
- package/src/kubernetes-controller-async.js +511 -0
- package/src/kubernetes-controller.js +878 -0
- package/src/kubernetes-resource-gateway.js +48 -0
- package/src/notification-controller.js +178 -0
- package/src/operations.js +112 -0
- package/src/org-scoping.js +5 -0
- package/src/resource-model.js +221 -0
- package/src/runner-controller.js +272 -0
- package/src/runners-ci.js +48 -0
- package/src/runtime.js +196 -0
- package/src/snapshot-cache.js +157 -0
- package/src/web-ui.js +40 -0
- package/tests/agent-adapter-controller.test.js +361 -0
- package/tests/agent-approval-controller.test.js +173 -0
- package/tests/agent-context-bundles.test.js +278 -0
- package/tests/agent-dispatch-controller.test.js +315 -0
- package/tests/agent-gateway-config-controller.test.js +386 -0
- package/tests/agent-memory-controller.test.js +308 -0
- package/tests/agent-memory-import-snapshot.test.js +477 -0
- package/tests/agent-memory-query.test.js +404 -0
- package/tests/agent-memory-repository-source.test.js +514 -0
- package/tests/agent-mux-client.test.js +204 -0
- package/tests/agent-permission-review-v2.test.js +317 -0
- package/tests/agent-permission-review.test.js +209 -0
- package/tests/agent-project-controller.test.js +302 -0
- package/tests/agent-provider-config-controller.test.js +376 -0
- package/tests/agent-resources.test.js +228 -0
- package/tests/agent-secret-config-grant.test.js +231 -0
- package/tests/agent-session-transcript-controller.test.js +499 -0
- package/tests/agent-stack-controller.test.js +221 -0
- package/tests/agent-subagent-controller.test.js +201 -0
- package/tests/agent-transport-binding-controller.test.js +294 -0
- package/tests/agent-trigger-controller.test.js +211 -0
- package/tests/agent-trigger-routes.test.js +190 -0
- package/tests/agent-trigger-sources.test.js +245 -0
- package/tests/agent-workspace-controller.test.js +181 -0
- package/tests/agent-writeback.test.js +292 -0
- package/tests/approval-persistence.test.js +171 -0
- package/tests/async-controller.test.js +252 -0
- package/tests/audit-controller.test.js +227 -0
- package/tests/codespace-controller.test.js +318 -0
- package/tests/deployment.test.js +407 -0
- package/tests/e2e/lifecycle.test.js +117 -0
- package/tests/event-bus-integration.test.js +190 -0
- package/tests/external-github-forge.test.js +560 -0
- package/tests/external-github-issues-cicd.test.js +520 -0
- package/tests/external-integration.test.js +470 -0
- package/tests/external-persistence.test.js +340 -0
- package/tests/external-provider-adapter.test.js +365 -0
- package/tests/external-resource-model.test.js +215 -0
- package/tests/external-webhook-sync.test.js +287 -0
- package/tests/external-write-conflict.test.js +353 -0
- package/tests/gitea-service.test.js +253 -0
- package/tests/health-check-real.test.js +165 -0
- package/tests/integration/full-flow.test.js +266 -0
- package/tests/krate.test.js +756 -0
- package/tests/memory-search-wiring.test.js +270 -0
- package/tests/notification-controller.test.js +196 -0
- package/tests/notification-integration.test.js +179 -0
- package/tests/org-scoping.test.js +687 -0
- package/tests/runner-controller.test.js +327 -0
- package/tests/runner-integration.test.js +231 -0
- package/tests/session-cookie-hmac.test.js +151 -0
- package/tests/snapshot-performance.test.js +247 -0
- package/tests/sse-events.test.js +107 -0
- package/tests/webhook-trigger.test.js +198 -0
- package/tests/workspace-volumes.test.js +312 -0
- package/tests/writeback-persistence.test.js +207 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// External Sync Controller — Slice 3.4
|
|
2
|
+
//
|
|
3
|
+
// Manages bidirectional sync between external providers and the Krate resource store.
|
|
4
|
+
//
|
|
5
|
+
// Responsibilities:
|
|
6
|
+
// - Event normalization (raw provider event → canonical internal format)
|
|
7
|
+
// - Resource upsert with external identity envelope (nativeId, url, etag)
|
|
8
|
+
// - High-watermark tracking per binding
|
|
9
|
+
// - Ownership mode arbitration (bidirectional / external-owned / krate-owned)
|
|
10
|
+
// - Tombstone creation for deleted external resources
|
|
11
|
+
|
|
12
|
+
export const SYNC_CONTROLLER_BOUNDARY = {
|
|
13
|
+
role: 'sync-controller',
|
|
14
|
+
scope: 'Bidirectional sync — event normalization, resource upsert, watermark, ownership, tombstones',
|
|
15
|
+
owns: ['event normalization', 'resource upsert', 'watermark tracking', 'ownership modes', 'tombstones'],
|
|
16
|
+
delegatesTo: ['resource-model'],
|
|
17
|
+
mustNotOwn: ['HMAC verification', 'webhook delivery tracking']
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a sync controller that manages bidirectional sync between external
|
|
22
|
+
* providers and the Krate resource store.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ persistFn?: (resource: object) => Promise<any> }} [opts]
|
|
25
|
+
* Optional persistFn is called (fire-and-forget) after watermark/resource changes.
|
|
26
|
+
* It receives a K8s-style CRD resource representing the changed state.
|
|
27
|
+
* @returns {object}
|
|
28
|
+
*/
|
|
29
|
+
export function createSyncController({ persistFn } = {}) {
|
|
30
|
+
/** @type {Map<string, string>} bindingRef → ISO timestamp watermark */
|
|
31
|
+
const watermarks = new Map();
|
|
32
|
+
|
|
33
|
+
/** @type {Map<string, object>} `${namespace}/${kind}/${localName}` → resource */
|
|
34
|
+
const resources = new Map();
|
|
35
|
+
|
|
36
|
+
/** @type {Map<string, object>} nativeId → tombstone record */
|
|
37
|
+
const tombstones = new Map();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fire-and-forget persistence: call persistFn without blocking the caller.
|
|
41
|
+
* @param {object} resource
|
|
42
|
+
*/
|
|
43
|
+
function persist(resource) {
|
|
44
|
+
if (typeof persistFn === 'function') {
|
|
45
|
+
// Intentionally not awaited — persistence is async and non-blocking
|
|
46
|
+
Promise.resolve(persistFn(resource)).catch(() => {
|
|
47
|
+
// Swallow errors in fire-and-forget path; caller may wire monitoring separately
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
/**
|
|
54
|
+
* Normalize a raw provider event into a canonical internal format.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ eventType: string, action?: string, nativeId: string, providerRef: string,
|
|
57
|
+
* resourceKind: string, data?: object, receivedAt?: string }} rawEvent
|
|
58
|
+
* @returns {{ eventType: string, action: string, nativeId: string, providerRef: string,
|
|
59
|
+
* resourceKind: string, data: object, receivedAt: string, canonicalAt: string }}
|
|
60
|
+
*/
|
|
61
|
+
normalizeEvent(rawEvent) {
|
|
62
|
+
return {
|
|
63
|
+
eventType: rawEvent.eventType,
|
|
64
|
+
action: rawEvent.action || 'unknown',
|
|
65
|
+
nativeId: rawEvent.nativeId,
|
|
66
|
+
providerRef: rawEvent.providerRef,
|
|
67
|
+
resourceKind: rawEvent.resourceKind,
|
|
68
|
+
data: rawEvent.data || {},
|
|
69
|
+
receivedAt: rawEvent.receivedAt || new Date().toISOString(),
|
|
70
|
+
canonicalAt: new Date().toISOString()
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Upsert a resource in the local store with an external identity envelope.
|
|
76
|
+
*
|
|
77
|
+
* If the resource already exists (matched by localName + namespace + kind), it will
|
|
78
|
+
* be updated while preserving the nativeId and firstSyncedAt from the first sync.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ kind: string, localName: string, namespace?: string, spec: object,
|
|
81
|
+
* externalEnvelope: { nativeId: string, url: string, etag: string, providerRef: string } }} params
|
|
82
|
+
* @returns {object} The created/updated resource
|
|
83
|
+
*/
|
|
84
|
+
upsertResource({ kind, localName, namespace = 'default', spec, externalEnvelope }) {
|
|
85
|
+
const key = `${namespace}/${kind}/${localName}`;
|
|
86
|
+
const existing = resources.get(key);
|
|
87
|
+
const now = new Date().toISOString();
|
|
88
|
+
|
|
89
|
+
const resource = {
|
|
90
|
+
apiVersion: 'krate.a5c.ai/v1alpha1',
|
|
91
|
+
kind,
|
|
92
|
+
metadata: {
|
|
93
|
+
name: localName,
|
|
94
|
+
namespace,
|
|
95
|
+
labels: {},
|
|
96
|
+
annotations: {}
|
|
97
|
+
},
|
|
98
|
+
spec: { ...spec },
|
|
99
|
+
status: {
|
|
100
|
+
phase: 'Synced',
|
|
101
|
+
external: {
|
|
102
|
+
nativeId: externalEnvelope.nativeId,
|
|
103
|
+
url: externalEnvelope.url,
|
|
104
|
+
etag: externalEnvelope.etag,
|
|
105
|
+
providerRef: externalEnvelope.providerRef,
|
|
106
|
+
lastSyncedAt: now,
|
|
107
|
+
firstSyncedAt: existing
|
|
108
|
+
? existing.status.external.firstSyncedAt
|
|
109
|
+
: now
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
resources.set(key, resource);
|
|
115
|
+
persist(resource);
|
|
116
|
+
return resource;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Advance the high-watermark timestamp for a given binding.
|
|
121
|
+
* If the new timestamp is not later than the existing one, it is ignored.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} bindingRef
|
|
124
|
+
* @param {string} timestamp ISO 8601 timestamp
|
|
125
|
+
*/
|
|
126
|
+
updateWatermark(bindingRef, timestamp) {
|
|
127
|
+
const current = watermarks.get(bindingRef);
|
|
128
|
+
if (!current || timestamp > current) {
|
|
129
|
+
watermarks.set(bindingRef, timestamp);
|
|
130
|
+
// Persist watermark as a CRD-shaped resource
|
|
131
|
+
persist({
|
|
132
|
+
apiVersion: 'krate.a5c.ai/v1alpha1',
|
|
133
|
+
kind: 'ExternalSyncWatermark',
|
|
134
|
+
metadata: {
|
|
135
|
+
name: `watermark-${bindingRef.replace(/[^a-zA-Z0-9]/g, '-')}`,
|
|
136
|
+
namespace: 'default',
|
|
137
|
+
labels: {},
|
|
138
|
+
annotations: {}
|
|
139
|
+
},
|
|
140
|
+
spec: {
|
|
141
|
+
bindingRef,
|
|
142
|
+
watermark: timestamp
|
|
143
|
+
},
|
|
144
|
+
status: {
|
|
145
|
+
lastUpdatedAt: new Date().toISOString()
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Retrieve the current high-watermark for a given binding.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} bindingRef
|
|
155
|
+
* @returns {string|null}
|
|
156
|
+
*/
|
|
157
|
+
getWatermark(bindingRef) {
|
|
158
|
+
return watermarks.has(bindingRef) ? watermarks.get(bindingRef) : null;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Apply an ownership mode policy to determine whether an operation is allowed.
|
|
163
|
+
*
|
|
164
|
+
* Modes:
|
|
165
|
+
* - 'bidirectional' — both krate and external provider may write
|
|
166
|
+
* - 'external-owned' — only the external provider may write; krate is read-only
|
|
167
|
+
* - 'krate-owned' — only krate may write; external provider is read-only
|
|
168
|
+
*
|
|
169
|
+
* @param {{ ownershipMode: string, operation: string, origin: string }} params
|
|
170
|
+
* @returns {{ allowed: boolean, reason: string|null }}
|
|
171
|
+
*/
|
|
172
|
+
applyOwnershipMode({ ownershipMode, operation, origin }) {
|
|
173
|
+
if (ownershipMode === 'bidirectional') {
|
|
174
|
+
return { allowed: true, reason: null };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (ownershipMode === 'external-owned') {
|
|
178
|
+
if (origin === 'krate' && operation === 'write') {
|
|
179
|
+
return {
|
|
180
|
+
allowed: false,
|
|
181
|
+
reason: 'external-owned mode — krate writes are blocked; this resource is read-only from krate perspective'
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { allowed: true, reason: null };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (ownershipMode === 'krate-owned') {
|
|
188
|
+
if (origin === 'external' && operation === 'write') {
|
|
189
|
+
return {
|
|
190
|
+
allowed: false,
|
|
191
|
+
reason: 'krate-owned mode — external provider writes are blocked; krate is the authoritative source'
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { allowed: true, reason: null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Unknown mode — deny by default
|
|
198
|
+
return {
|
|
199
|
+
allowed: false,
|
|
200
|
+
reason: `unknown ownership mode: ${ownershipMode}`
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create a tombstone record marking that an external resource has been deleted.
|
|
206
|
+
*
|
|
207
|
+
* @param {{ nativeId: string, providerRef: string, resourceKind: string,
|
|
208
|
+
* localRef: string, deletedAt?: string }} params
|
|
209
|
+
* @returns {{ nativeId: string, providerRef: string, resourceKind: string,
|
|
210
|
+
* localRef: string, tombstoned: true, deletedAt: string }}
|
|
211
|
+
*/
|
|
212
|
+
createTombstone({ nativeId, providerRef, resourceKind, localRef, deletedAt }) {
|
|
213
|
+
const record = {
|
|
214
|
+
nativeId,
|
|
215
|
+
providerRef,
|
|
216
|
+
resourceKind,
|
|
217
|
+
localRef,
|
|
218
|
+
tombstoned: true,
|
|
219
|
+
deletedAt: deletedAt || new Date().toISOString()
|
|
220
|
+
};
|
|
221
|
+
tombstones.set(nativeId, record);
|
|
222
|
+
return record;
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Retrieve a tombstone record by nativeId, or null if not found.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} nativeId
|
|
229
|
+
* @returns {object|null}
|
|
230
|
+
*/
|
|
231
|
+
getTombstone(nativeId) {
|
|
232
|
+
return tombstones.has(nativeId) ? tombstones.get(nativeId) : null;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// External Webhook Controller — Slice 3.4
|
|
2
|
+
//
|
|
3
|
+
// Handles inbound webhook delivery:
|
|
4
|
+
// - HMAC-SHA256 signature verification
|
|
5
|
+
// - Delivery record creation and persistence (in-memory store)
|
|
6
|
+
// - Deduplication by deliveryId
|
|
7
|
+
// - Async event queue with subscriber support
|
|
8
|
+
|
|
9
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
export const WEBHOOK_CONTROLLER_BOUNDARY = {
|
|
12
|
+
role: 'webhook-controller',
|
|
13
|
+
scope: 'Inbound webhook delivery — HMAC verification, dedup, async event queue',
|
|
14
|
+
owns: ['HMAC validation', 'delivery records', 'dedup index', 'event queue'],
|
|
15
|
+
delegatesTo: ['sync-controller'],
|
|
16
|
+
mustNotOwn: ['resource persistence', 'ownership arbitration']
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a webhook controller that handles inbound webhook delivery.
|
|
21
|
+
*
|
|
22
|
+
* @param {{ secret: string }} options
|
|
23
|
+
* @returns {object}
|
|
24
|
+
*/
|
|
25
|
+
export function createWebhookController(options = {}) {
|
|
26
|
+
const { secret } = options;
|
|
27
|
+
|
|
28
|
+
/** @type {Map<string, object>} deliveryId → delivery record */
|
|
29
|
+
const deliveries = new Map();
|
|
30
|
+
|
|
31
|
+
/** @type {Function[]} event subscribers */
|
|
32
|
+
const subscribers = [];
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
/**
|
|
36
|
+
* Verify an HMAC-SHA256 signature against a request body.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} body Raw request body string
|
|
39
|
+
* @param {string|null} signature Signature header value (e.g. "sha256=abc…")
|
|
40
|
+
* @returns {{ valid: boolean, reason: string|null }}
|
|
41
|
+
*/
|
|
42
|
+
verifyHmacSignature(body, signature) {
|
|
43
|
+
if (!signature) {
|
|
44
|
+
return { valid: false, reason: 'missing signature header' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!signature.startsWith('sha256=')) {
|
|
48
|
+
return { valid: false, reason: 'invalid signature format — must start with sha256=' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
55
|
+
const actualBuf = Buffer.from(signature, 'utf8');
|
|
56
|
+
if (expectedBuf.length !== actualBuf.length) {
|
|
57
|
+
return { valid: false, reason: 'signature mismatch' };
|
|
58
|
+
}
|
|
59
|
+
const match = timingSafeEqual(expectedBuf, actualBuf);
|
|
60
|
+
if (!match) {
|
|
61
|
+
return { valid: false, reason: 'signature mismatch — HMAC does not match' };
|
|
62
|
+
}
|
|
63
|
+
return { valid: true, reason: null };
|
|
64
|
+
} catch {
|
|
65
|
+
return { valid: false, reason: 'signature verification error — invalid signature bytes' };
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a delivery record for a received webhook payload.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ deliveryId: string, eventType: string, payload: object, rawBody: string }} params
|
|
73
|
+
* @returns {{ deliveryId: string, eventType: string, timestamp: string, payload: object, rawBody: string, status: string }}
|
|
74
|
+
*/
|
|
75
|
+
createDeliveryRecord({ deliveryId, eventType, payload, rawBody }) {
|
|
76
|
+
return {
|
|
77
|
+
deliveryId,
|
|
78
|
+
eventType,
|
|
79
|
+
timestamp: new Date().toISOString(),
|
|
80
|
+
payload,
|
|
81
|
+
rawBody,
|
|
82
|
+
status: 'received'
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Persist a delivery record into the in-memory dedup store.
|
|
88
|
+
*
|
|
89
|
+
* @param {{ deliveryId: string }} record
|
|
90
|
+
*/
|
|
91
|
+
recordDelivery(record) {
|
|
92
|
+
deliveries.set(record.deliveryId, record);
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check whether a deliveryId has already been processed.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} deliveryId
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
isDuplicate(deliveryId) {
|
|
102
|
+
return deliveries.has(deliveryId);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Register a subscriber that will be called for each queued normalized event.
|
|
107
|
+
*
|
|
108
|
+
* @param {Function} handler fn(event) → void
|
|
109
|
+
*/
|
|
110
|
+
onEvent(handler) {
|
|
111
|
+
subscribers.push(handler);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Process a delivery: create a record, check dedup, emit to queue.
|
|
116
|
+
*
|
|
117
|
+
* @param {{ deliveryId: string, eventType: string, payload: object, rawBody: string }} params
|
|
118
|
+
* @returns {{ queued: number, duplicate: boolean, deliveryId: string }}
|
|
119
|
+
*/
|
|
120
|
+
processDelivery({ deliveryId, eventType, payload, rawBody }) {
|
|
121
|
+
if (this.isDuplicate(deliveryId)) {
|
|
122
|
+
return { queued: 0, duplicate: true, deliveryId };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const record = this.createDeliveryRecord({ deliveryId, eventType, payload, rawBody });
|
|
126
|
+
this.recordDelivery(record);
|
|
127
|
+
|
|
128
|
+
const normalizedEvent = {
|
|
129
|
+
deliveryId,
|
|
130
|
+
eventType,
|
|
131
|
+
payload,
|
|
132
|
+
receivedAt: record.timestamp
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let queued = 0;
|
|
136
|
+
for (const subscriber of subscribers) {
|
|
137
|
+
subscriber(normalizedEvent);
|
|
138
|
+
queued++;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { queued, duplicate: false, deliveryId };
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// External Write Controller — Slice 3.5
|
|
2
|
+
// Manages WriteIntent lifecycle: creation, approval enforcement, idempotency,
|
|
3
|
+
// retry logic, and confirmation recording.
|
|
4
|
+
|
|
5
|
+
import { createResource, clone } from '../resource-model.js';
|
|
6
|
+
|
|
7
|
+
export const WRITE_CONTROLLER_BOUNDARY = {
|
|
8
|
+
role: 'external-write-controller',
|
|
9
|
+
scope: 'WriteIntent lifecycle — creation, approval, rejection, execution with retry',
|
|
10
|
+
owns: ['WriteIntent creation', 'approval gate', 'retry logic', 'idempotency key'],
|
|
11
|
+
delegatesTo: ['resource-model'],
|
|
12
|
+
mustNotOwn: ['conflict resolution', 'sync state', 'external API client']
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const VALID_PHASES = ['PendingApproval', 'ReadyToSend', 'Sending', 'Retrying', 'Succeeded', 'Failed', 'Rejected'];
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Idempotency key
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a deterministic idempotency key for a write operation.
|
|
23
|
+
* The key is stable for the same (interfaceKey, operation, resourceRef, payload).
|
|
24
|
+
*
|
|
25
|
+
* @param {{ interfaceKey: string, operation: string, resourceRef: string, payload: object }} input
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function getIdempotencyKey({ interfaceKey, operation, resourceRef, payload }) {
|
|
29
|
+
const canonical = JSON.stringify({ interfaceKey, operation, resourceRef, payload }, Object.keys({ interfaceKey, operation, resourceRef, payload }).sort());
|
|
30
|
+
// Simple deterministic hash using djb2-style accumulation (no external deps)
|
|
31
|
+
let hash = 5381;
|
|
32
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
33
|
+
hash = ((hash << 5) + hash) ^ canonical.charCodeAt(i);
|
|
34
|
+
hash = hash >>> 0; // keep 32-bit unsigned
|
|
35
|
+
}
|
|
36
|
+
return `idem-${interfaceKey}-${operation}-${hash.toString(16)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Validation
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate a WriteIntent input object.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} input
|
|
47
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
48
|
+
*/
|
|
49
|
+
export function validateWriteIntent(input) {
|
|
50
|
+
const errors = [];
|
|
51
|
+
if (!input) {
|
|
52
|
+
return { valid: false, errors: ['input must not be null or undefined'] };
|
|
53
|
+
}
|
|
54
|
+
if (!input.interfaceKey || typeof input.interfaceKey !== 'string') {
|
|
55
|
+
errors.push('interfaceKey is required and must be a non-empty string');
|
|
56
|
+
}
|
|
57
|
+
if (!input.operation || typeof input.operation !== 'string') {
|
|
58
|
+
errors.push('operation is required and must be a non-empty string');
|
|
59
|
+
}
|
|
60
|
+
if (!input.resourceRef || typeof input.resourceRef !== 'string') {
|
|
61
|
+
errors.push('resourceRef is required and must be a non-empty string');
|
|
62
|
+
}
|
|
63
|
+
return { valid: errors.length === 0, errors };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Controller factory
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a WriteController that manages ExternalWriteIntent resources.
|
|
72
|
+
*
|
|
73
|
+
* @param {{ persistFn?: (resource: object) => Promise<any> }} [opts]
|
|
74
|
+
* Optional persistFn is called (fire-and-forget) after intent state changes.
|
|
75
|
+
* @returns {object}
|
|
76
|
+
*/
|
|
77
|
+
export function createWriteController({ persistFn } = {}) {
|
|
78
|
+
/**
|
|
79
|
+
* Fire-and-forget persistence helper.
|
|
80
|
+
* @param {object} resource
|
|
81
|
+
*/
|
|
82
|
+
function persist(resource) {
|
|
83
|
+
if (typeof persistFn === 'function') {
|
|
84
|
+
Promise.resolve(persistFn(resource)).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
role: 'write-controller',
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a new ExternalWriteIntent resource.
|
|
93
|
+
* If requiresApproval is true, phase starts as PendingApproval.
|
|
94
|
+
* If requiresApproval is false, phase starts as ReadyToSend.
|
|
95
|
+
*
|
|
96
|
+
* @param {{ interfaceKey, operation, payload, resourceRef, requiresApproval?, maxRetries?, namespace?, organizationRef? }} input
|
|
97
|
+
* @returns {{ intent: object } | { error: true, message: string }}
|
|
98
|
+
*/
|
|
99
|
+
createWriteIntent({
|
|
100
|
+
interfaceKey,
|
|
101
|
+
operation,
|
|
102
|
+
payload = {},
|
|
103
|
+
resourceRef,
|
|
104
|
+
requiresApproval = false,
|
|
105
|
+
maxRetries = 3,
|
|
106
|
+
namespace = 'default',
|
|
107
|
+
organizationRef = 'default'
|
|
108
|
+
}) {
|
|
109
|
+
const validation = validateWriteIntent({ interfaceKey, operation, resourceRef });
|
|
110
|
+
if (!validation.valid) {
|
|
111
|
+
return { error: true, message: validation.errors.join('; ') };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const idempotencyKey = getIdempotencyKey({ interfaceKey, operation, resourceRef, payload });
|
|
115
|
+
const now = new Date().toISOString();
|
|
116
|
+
const intentName = `write-intent-${idempotencyKey}-${Date.now()}`;
|
|
117
|
+
const phase = requiresApproval ? 'PendingApproval' : 'ReadyToSend';
|
|
118
|
+
|
|
119
|
+
const intent = createResource('ExternalWriteIntent', { name: intentName, namespace }, {
|
|
120
|
+
organizationRef,
|
|
121
|
+
interfaceKey,
|
|
122
|
+
operation,
|
|
123
|
+
payload,
|
|
124
|
+
resourceRef,
|
|
125
|
+
requiresApproval,
|
|
126
|
+
maxRetries,
|
|
127
|
+
idempotencyKey
|
|
128
|
+
});
|
|
129
|
+
intent.status = {
|
|
130
|
+
phase,
|
|
131
|
+
retryCount: 0,
|
|
132
|
+
createdAt: now
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
persist(intent);
|
|
136
|
+
return { intent };
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Approve a PendingApproval intent, transitioning it to ReadyToSend.
|
|
141
|
+
*
|
|
142
|
+
* @param {{ intentName, approvedBy, resources }} opts
|
|
143
|
+
* @returns {{ intent: object } | { error: true, reason: string, message: string }}
|
|
144
|
+
*/
|
|
145
|
+
approveWriteIntent({ intentName, approvedBy, resources = {} }) {
|
|
146
|
+
if (!intentName) {
|
|
147
|
+
return { error: true, reason: 'missing-name', message: 'intentName is required' };
|
|
148
|
+
}
|
|
149
|
+
if (!approvedBy) {
|
|
150
|
+
return { error: true, reason: 'missing-approver', message: 'approvedBy is required' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const intents = resources.ExternalWriteIntent || [];
|
|
154
|
+
const found = intents.find((i) => i.metadata?.name === intentName);
|
|
155
|
+
if (!found) {
|
|
156
|
+
return { error: true, reason: 'not-found', message: `ExternalWriteIntent not found: ${intentName}` };
|
|
157
|
+
}
|
|
158
|
+
if (found.status?.phase !== 'PendingApproval') {
|
|
159
|
+
return { error: true, reason: 'invalid-phase', message: `Intent is not in PendingApproval: ${found.status?.phase}` };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const updated = clone(found);
|
|
163
|
+
updated.status = {
|
|
164
|
+
...updated.status,
|
|
165
|
+
phase: 'ReadyToSend',
|
|
166
|
+
approvedBy,
|
|
167
|
+
approvedAt: new Date().toISOString()
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
persist(updated);
|
|
171
|
+
return { intent: updated };
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reject a PendingApproval intent, transitioning it to Rejected.
|
|
176
|
+
*
|
|
177
|
+
* @param {{ intentName, rejectedBy, reason, resources }} opts
|
|
178
|
+
* @returns {{ intent: object } | { error: true, reason: string, message: string }}
|
|
179
|
+
*/
|
|
180
|
+
rejectWriteIntent({ intentName, rejectedBy, reason = '', resources = {} }) {
|
|
181
|
+
if (!intentName) {
|
|
182
|
+
return { error: true, reason: 'missing-name', message: 'intentName is required' };
|
|
183
|
+
}
|
|
184
|
+
if (!rejectedBy) {
|
|
185
|
+
return { error: true, reason: 'missing-rejecter', message: 'rejectedBy is required' };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const intents = resources.ExternalWriteIntent || [];
|
|
189
|
+
const found = intents.find((i) => i.metadata?.name === intentName);
|
|
190
|
+
if (!found) {
|
|
191
|
+
return { error: true, reason: 'not-found', message: `ExternalWriteIntent not found: ${intentName}` };
|
|
192
|
+
}
|
|
193
|
+
if (found.status?.phase !== 'PendingApproval') {
|
|
194
|
+
return { error: true, reason: 'invalid-phase', message: `Intent is not in PendingApproval: ${found.status?.phase}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const updated = clone(found);
|
|
198
|
+
updated.status = {
|
|
199
|
+
...updated.status,
|
|
200
|
+
phase: 'Rejected',
|
|
201
|
+
rejectedBy,
|
|
202
|
+
rejectedAt: new Date().toISOString(),
|
|
203
|
+
rejectionReason: reason
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
persist(updated);
|
|
207
|
+
return { intent: updated };
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Execute a WriteIntent by calling the provided executor function.
|
|
212
|
+
* Handles Sending → Succeeded / Retrying → Failed lifecycle.
|
|
213
|
+
*
|
|
214
|
+
* @param {{ intentName, resources, executor, onPhaseChange? }} opts
|
|
215
|
+
* @returns {Promise<{ intent: object } | { error: true, intent: object, message: string }>}
|
|
216
|
+
*/
|
|
217
|
+
async executeWriteIntent({ intentName, resources = {}, executor, onPhaseChange }) {
|
|
218
|
+
if (!intentName) {
|
|
219
|
+
return { error: true, message: 'intentName is required' };
|
|
220
|
+
}
|
|
221
|
+
if (typeof executor !== 'function') {
|
|
222
|
+
return { error: true, message: 'executor must be a function' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const intents = resources.ExternalWriteIntent || [];
|
|
226
|
+
const found = intents.find((i) => i.metadata?.name === intentName);
|
|
227
|
+
if (!found) {
|
|
228
|
+
return { error: true, message: `ExternalWriteIntent not found: ${intentName}` };
|
|
229
|
+
}
|
|
230
|
+
if (found.status?.phase !== 'ReadyToSend') {
|
|
231
|
+
return { error: true, message: `Intent is not ReadyToSend: ${found.status?.phase}` };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const maxRetries = found.spec?.maxRetries ?? 3;
|
|
235
|
+
let intent = clone(found);
|
|
236
|
+
|
|
237
|
+
// Transition to Sending
|
|
238
|
+
intent.status = { ...intent.status, phase: 'Sending', sendingAt: new Date().toISOString() };
|
|
239
|
+
if (onPhaseChange) onPhaseChange('Sending');
|
|
240
|
+
|
|
241
|
+
let lastError = null;
|
|
242
|
+
let attempt = 0;
|
|
243
|
+
|
|
244
|
+
while (attempt <= maxRetries) {
|
|
245
|
+
try {
|
|
246
|
+
const externalResult = await executor();
|
|
247
|
+
intent.status = {
|
|
248
|
+
...intent.status,
|
|
249
|
+
phase: 'Succeeded',
|
|
250
|
+
succeededAt: new Date().toISOString(),
|
|
251
|
+
externalResult,
|
|
252
|
+
retryCount: attempt
|
|
253
|
+
};
|
|
254
|
+
if (onPhaseChange) onPhaseChange('Succeeded');
|
|
255
|
+
return { intent };
|
|
256
|
+
} catch (err) {
|
|
257
|
+
lastError = err;
|
|
258
|
+
attempt++;
|
|
259
|
+
if (attempt <= maxRetries) {
|
|
260
|
+
intent.status = {
|
|
261
|
+
...intent.status,
|
|
262
|
+
phase: 'Retrying',
|
|
263
|
+
retryCount: attempt,
|
|
264
|
+
lastError: err.message
|
|
265
|
+
};
|
|
266
|
+
if (onPhaseChange) onPhaseChange('Retrying');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Exhausted retries
|
|
272
|
+
intent.status = {
|
|
273
|
+
...intent.status,
|
|
274
|
+
phase: 'Failed',
|
|
275
|
+
failedAt: new Date().toISOString(),
|
|
276
|
+
retryCount: attempt - 1,
|
|
277
|
+
lastError: lastError?.message ?? 'unknown error'
|
|
278
|
+
};
|
|
279
|
+
if (onPhaseChange) onPhaseChange('Failed');
|
|
280
|
+
return { error: true, intent, message: `Execution failed after ${attempt - 1} retries: ${lastError?.message}` };
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|