@smthrs/control 0.0.0-stage → 1.0.0-rc.3
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/CHANGELOG.md +194 -0
- package/LICENSE +21 -0
- package/README.md +168 -2
- package/dist/cjs/ApprovalAuthority.d.ts +73 -0
- package/dist/cjs/ApprovalAuthority.d.ts.map +1 -0
- package/dist/cjs/ApprovalAuthority.js +62 -0
- package/dist/cjs/ApprovalAuthority.js.map +7 -0
- package/dist/cjs/Cancellation.d.ts +107 -0
- package/dist/cjs/Cancellation.d.ts.map +1 -0
- package/dist/cjs/Cancellation.js +72 -0
- package/dist/cjs/Cancellation.js.map +7 -0
- package/dist/cjs/Channels.d.ts +170 -0
- package/dist/cjs/Channels.d.ts.map +1 -0
- package/dist/cjs/Channels.js +278 -0
- package/dist/cjs/Channels.js.map +7 -0
- package/dist/cjs/Control.d.ts +202 -0
- package/dist/cjs/Control.d.ts.map +1 -0
- package/dist/cjs/Control.js +47 -0
- package/dist/cjs/Control.js.map +7 -0
- package/dist/cjs/ControlClient.d.ts +52 -0
- package/dist/cjs/ControlClient.d.ts.map +1 -0
- package/dist/cjs/ControlClient.js +191 -0
- package/dist/cjs/ControlClient.js.map +7 -0
- package/dist/cjs/ControlError.d.ts +318 -0
- package/dist/cjs/ControlError.d.ts.map +1 -0
- package/dist/cjs/ControlError.js +249 -0
- package/dist/cjs/ControlError.js.map +7 -0
- package/dist/cjs/ControlExecutor.d.ts +372 -0
- package/dist/cjs/ControlExecutor.d.ts.map +1 -0
- package/dist/cjs/ControlExecutor.js +123 -0
- package/dist/cjs/ControlExecutor.js.map +7 -0
- package/dist/cjs/ControlFacts.d.ts +454 -0
- package/dist/cjs/ControlFacts.d.ts.map +1 -0
- package/dist/cjs/ControlFacts.js +261 -0
- package/dist/cjs/ControlFacts.js.map +7 -0
- package/dist/cjs/ControlLive.d.ts +23 -0
- package/dist/cjs/ControlLive.d.ts.map +1 -0
- package/dist/cjs/ControlLive.js +1280 -0
- package/dist/cjs/ControlLive.js.map +7 -0
- package/dist/cjs/ControlRpcs.d.ts +1204 -0
- package/dist/cjs/ControlRpcs.d.ts.map +1 -0
- package/dist/cjs/ControlRpcs.js +247 -0
- package/dist/cjs/ControlRpcs.js.map +7 -0
- package/dist/cjs/ControlRuntime.d.ts +635 -0
- package/dist/cjs/ControlRuntime.d.ts.map +1 -0
- package/dist/cjs/ControlRuntime.js +740 -0
- package/dist/cjs/ControlRuntime.js.map +7 -0
- package/dist/cjs/ControlSchema.d.ts +2642 -0
- package/dist/cjs/ControlSchema.d.ts.map +1 -0
- package/dist/cjs/ControlSchema.js +634 -0
- package/dist/cjs/ControlSchema.js.map +7 -0
- package/dist/cjs/ControlServer.d.ts +51 -0
- package/dist/cjs/ControlServer.d.ts.map +1 -0
- package/dist/cjs/ControlServer.js +121 -0
- package/dist/cjs/ControlServer.js.map +7 -0
- package/dist/cjs/Credential.d.ts +136 -0
- package/dist/cjs/Credential.d.ts.map +1 -0
- package/dist/cjs/Credential.js +168 -0
- package/dist/cjs/Credential.js.map +7 -0
- package/dist/cjs/CredentialCipher.d.ts +90 -0
- package/dist/cjs/CredentialCipher.d.ts.map +1 -0
- package/dist/cjs/CredentialCipher.js +45 -0
- package/dist/cjs/CredentialCipher.js.map +7 -0
- package/dist/cjs/CredentialStore.d.ts +97 -0
- package/dist/cjs/CredentialStore.d.ts.map +1 -0
- package/dist/cjs/CredentialStore.js +81 -0
- package/dist/cjs/CredentialStore.js.map +7 -0
- package/dist/cjs/DispatchReader.d.ts +112 -0
- package/dist/cjs/DispatchReader.d.ts.map +1 -0
- package/dist/cjs/DispatchReader.js +45 -0
- package/dist/cjs/DispatchReader.js.map +7 -0
- package/dist/cjs/Health.d.ts +333 -0
- package/dist/cjs/Health.d.ts.map +1 -0
- package/dist/cjs/Health.js +311 -0
- package/dist/cjs/Health.js.map +7 -0
- package/dist/cjs/JevSessionChecker.d.ts +57 -0
- package/dist/cjs/JevSessionChecker.d.ts.map +1 -0
- package/dist/cjs/JevSessionChecker.js +113 -0
- package/dist/cjs/JevSessionChecker.js.map +7 -0
- package/dist/cjs/Lineage.d.ts +131 -0
- package/dist/cjs/Lineage.d.ts.map +1 -0
- package/dist/cjs/Lineage.js +81 -0
- package/dist/cjs/Lineage.js.map +7 -0
- package/dist/cjs/Migrations.d.ts +34 -0
- package/dist/cjs/Migrations.d.ts.map +1 -0
- package/dist/cjs/Migrations.js +60 -0
- package/dist/cjs/Migrations.js.map +7 -0
- package/dist/cjs/Monitor.d.ts +282 -0
- package/dist/cjs/Monitor.d.ts.map +1 -0
- package/dist/cjs/Monitor.js +283 -0
- package/dist/cjs/Monitor.js.map +7 -0
- package/dist/cjs/ScopedToken.d.ts +193 -0
- package/dist/cjs/ScopedToken.d.ts.map +1 -0
- package/dist/cjs/ScopedToken.js +135 -0
- package/dist/cjs/ScopedToken.js.map +7 -0
- package/dist/cjs/SqlControlRuntime.d.ts +161 -0
- package/dist/cjs/SqlControlRuntime.d.ts.map +1 -0
- package/dist/cjs/SqlControlRuntime.js +1521 -0
- package/dist/cjs/SqlControlRuntime.js.map +7 -0
- package/dist/cjs/SqlCredentialStore.d.ts +43 -0
- package/dist/cjs/SqlCredentialStore.d.ts.map +1 -0
- package/dist/cjs/SqlCredentialStore.js +113 -0
- package/dist/cjs/SqlCredentialStore.js.map +7 -0
- package/dist/cjs/Steering.d.ts +69 -0
- package/dist/cjs/Steering.d.ts.map +1 -0
- package/dist/cjs/Steering.js +49 -0
- package/dist/cjs/Steering.js.map +7 -0
- package/dist/cjs/SystemFlows.d.ts +223 -0
- package/dist/cjs/SystemFlows.d.ts.map +1 -0
- package/dist/cjs/SystemFlows.js +195 -0
- package/dist/cjs/SystemFlows.js.map +7 -0
- package/dist/cjs/WebCryptoCipher.d.ts +49 -0
- package/dist/cjs/WebCryptoCipher.d.ts.map +1 -0
- package/dist/cjs/WebCryptoCipher.js +129 -0
- package/dist/cjs/WebCryptoCipher.js.map +7 -0
- package/dist/cjs/WebhookChannel.d.ts +113 -0
- package/dist/cjs/WebhookChannel.d.ts.map +1 -0
- package/dist/cjs/WebhookChannel.js +98 -0
- package/dist/cjs/WebhookChannel.js.map +7 -0
- package/dist/cjs/index.d.ts +160 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +91 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/MutationBoundary.d.ts +27 -0
- package/dist/cjs/internal/MutationBoundary.d.ts.map +1 -0
- package/dist/cjs/internal/MutationBoundary.js +50 -0
- package/dist/cjs/internal/MutationBoundary.js.map +7 -0
- package/dist/cjs/internal/activeFibers.d.ts +12 -0
- package/dist/cjs/internal/activeFibers.d.ts.map +1 -0
- package/dist/cjs/internal/activeFibers.js +30 -0
- package/dist/cjs/internal/activeFibers.js.map +7 -0
- package/dist/cjs/internal/issues.d.ts +28 -0
- package/dist/cjs/internal/issues.d.ts.map +1 -0
- package/dist/cjs/internal/issues.js +34 -0
- package/dist/cjs/internal/issues.js.map +7 -0
- package/dist/cjs/internal/planning.d.ts +347 -0
- package/dist/cjs/internal/planning.d.ts.map +1 -0
- package/dist/cjs/internal/planning.js +137 -0
- package/dist/cjs/internal/planning.js.map +7 -0
- package/dist/cjs/internal/sqlSchemaErrors.d.ts +18 -0
- package/dist/cjs/internal/sqlSchemaErrors.d.ts.map +1 -0
- package/dist/cjs/internal/sqlSchemaErrors.js +40 -0
- package/dist/cjs/internal/sqlSchemaErrors.js.map +7 -0
- package/dist/cjs/migrations/0001_control_tables.d.ts +18 -0
- package/dist/cjs/migrations/0001_control_tables.d.ts.map +1 -0
- package/dist/cjs/migrations/0001_control_tables.js +115 -0
- package/dist/cjs/migrations/0001_control_tables.js.map +7 -0
- package/dist/cjs/migrations/0002_run_keys.d.ts +15 -0
- package/dist/cjs/migrations/0002_run_keys.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_run_keys.js +44 -0
- package/dist/cjs/migrations/0002_run_keys.js.map +7 -0
- package/dist/cjs/migrations/0003_signal_commands.d.ts +13 -0
- package/dist/cjs/migrations/0003_signal_commands.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_signal_commands.js +50 -0
- package/dist/cjs/migrations/0003_signal_commands.js.map +7 -0
- package/dist/cjs/migrations/0004_approval_decisions.d.ts +13 -0
- package/dist/cjs/migrations/0004_approval_decisions.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_approval_decisions.js +45 -0
- package/dist/cjs/migrations/0004_approval_decisions.js.map +7 -0
- package/dist/cjs/migrations/0005_signal_principals.d.ts +18 -0
- package/dist/cjs/migrations/0005_signal_principals.d.ts.map +1 -0
- package/dist/cjs/migrations/0005_signal_principals.js +45 -0
- package/dist/cjs/migrations/0005_signal_principals.js.map +7 -0
- package/dist/cjs/migrations/0006_run_principals.d.ts +18 -0
- package/dist/cjs/migrations/0006_run_principals.d.ts.map +1 -0
- package/dist/cjs/migrations/0006_run_principals.js +49 -0
- package/dist/cjs/migrations/0006_run_principals.js.map +7 -0
- package/dist/cjs/migrations/0007_resume_consent.d.ts +18 -0
- package/dist/cjs/migrations/0007_resume_consent.d.ts.map +1 -0
- package/dist/cjs/migrations/0007_resume_consent.js +46 -0
- package/dist/cjs/migrations/0007_resume_consent.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/TestControl.d.ts +19 -0
- package/dist/cjs/test/TestControl.d.ts.map +1 -0
- package/dist/cjs/test/TestControl.js +62 -0
- package/dist/cjs/test/TestControl.js.map +7 -0
- package/dist/esm/ApprovalAuthority.d.ts +73 -0
- package/dist/esm/ApprovalAuthority.d.ts.map +1 -0
- package/dist/esm/ApprovalAuthority.js +72 -0
- package/dist/esm/ApprovalAuthority.js.map +1 -0
- package/dist/esm/Cancellation.d.ts +107 -0
- package/dist/esm/Cancellation.d.ts.map +1 -0
- package/dist/esm/Cancellation.js +116 -0
- package/dist/esm/Cancellation.js.map +1 -0
- package/dist/esm/Channels.d.ts +170 -0
- package/dist/esm/Channels.d.ts.map +1 -0
- package/dist/esm/Channels.js +312 -0
- package/dist/esm/Channels.js.map +1 -0
- package/dist/esm/Control.d.ts +202 -0
- package/dist/esm/Control.d.ts.map +1 -0
- package/dist/esm/Control.js +42 -0
- package/dist/esm/Control.js.map +1 -0
- package/dist/esm/ControlClient.d.ts +52 -0
- package/dist/esm/ControlClient.d.ts.map +1 -0
- package/dist/esm/ControlClient.js +217 -0
- package/dist/esm/ControlClient.js.map +1 -0
- package/dist/esm/ControlError.d.ts +318 -0
- package/dist/esm/ControlError.d.ts.map +1 -0
- package/dist/esm/ControlError.js +359 -0
- package/dist/esm/ControlError.js.map +1 -0
- package/dist/esm/ControlExecutor.d.ts +372 -0
- package/dist/esm/ControlExecutor.d.ts.map +1 -0
- package/dist/esm/ControlExecutor.js +212 -0
- package/dist/esm/ControlExecutor.js.map +1 -0
- package/dist/esm/ControlFacts.d.ts +454 -0
- package/dist/esm/ControlFacts.d.ts.map +1 -0
- package/dist/esm/ControlFacts.js +324 -0
- package/dist/esm/ControlFacts.js.map +1 -0
- package/dist/esm/ControlLive.d.ts +23 -0
- package/dist/esm/ControlLive.d.ts.map +1 -0
- package/dist/esm/ControlLive.js +1585 -0
- package/dist/esm/ControlLive.js.map +1 -0
- package/dist/esm/ControlRpcs.d.ts +1204 -0
- package/dist/esm/ControlRpcs.d.ts.map +1 -0
- package/dist/esm/ControlRpcs.js +299 -0
- package/dist/esm/ControlRpcs.js.map +1 -0
- package/dist/esm/ControlRuntime.d.ts +635 -0
- package/dist/esm/ControlRuntime.d.ts.map +1 -0
- package/dist/esm/ControlRuntime.js +807 -0
- package/dist/esm/ControlRuntime.js.map +1 -0
- package/dist/esm/ControlSchema.d.ts +2642 -0
- package/dist/esm/ControlSchema.d.ts.map +1 -0
- package/dist/esm/ControlSchema.js +1030 -0
- package/dist/esm/ControlSchema.js.map +1 -0
- package/dist/esm/ControlServer.d.ts +51 -0
- package/dist/esm/ControlServer.d.ts.map +1 -0
- package/dist/esm/ControlServer.js +145 -0
- package/dist/esm/ControlServer.js.map +1 -0
- package/dist/esm/Credential.d.ts +136 -0
- package/dist/esm/Credential.d.ts.map +1 -0
- package/dist/esm/Credential.js +190 -0
- package/dist/esm/Credential.js.map +1 -0
- package/dist/esm/CredentialCipher.d.ts +90 -0
- package/dist/esm/CredentialCipher.d.ts.map +1 -0
- package/dist/esm/CredentialCipher.js +56 -0
- package/dist/esm/CredentialCipher.js.map +1 -0
- package/dist/esm/CredentialStore.d.ts +97 -0
- package/dist/esm/CredentialStore.d.ts.map +1 -0
- package/dist/esm/CredentialStore.js +101 -0
- package/dist/esm/CredentialStore.js.map +1 -0
- package/dist/esm/DispatchReader.d.ts +112 -0
- package/dist/esm/DispatchReader.d.ts.map +1 -0
- package/dist/esm/DispatchReader.js +76 -0
- package/dist/esm/DispatchReader.js.map +1 -0
- package/dist/esm/Health.d.ts +333 -0
- package/dist/esm/Health.d.ts.map +1 -0
- package/dist/esm/Health.js +400 -0
- package/dist/esm/Health.js.map +1 -0
- package/dist/esm/JevSessionChecker.d.ts +57 -0
- package/dist/esm/JevSessionChecker.d.ts.map +1 -0
- package/dist/esm/JevSessionChecker.js +108 -0
- package/dist/esm/JevSessionChecker.js.map +1 -0
- package/dist/esm/Lineage.d.ts +131 -0
- package/dist/esm/Lineage.d.ts.map +1 -0
- package/dist/esm/Lineage.js +174 -0
- package/dist/esm/Lineage.js.map +1 -0
- package/dist/esm/Migrations.d.ts +34 -0
- package/dist/esm/Migrations.d.ts.map +1 -0
- package/dist/esm/Migrations.js +53 -0
- package/dist/esm/Migrations.js.map +1 -0
- package/dist/esm/Monitor.d.ts +282 -0
- package/dist/esm/Monitor.d.ts.map +1 -0
- package/dist/esm/Monitor.js +415 -0
- package/dist/esm/Monitor.js.map +1 -0
- package/dist/esm/ScopedToken.d.ts +193 -0
- package/dist/esm/ScopedToken.d.ts.map +1 -0
- package/dist/esm/ScopedToken.js +224 -0
- package/dist/esm/ScopedToken.js.map +1 -0
- package/dist/esm/SqlControlRuntime.d.ts +161 -0
- package/dist/esm/SqlControlRuntime.d.ts.map +1 -0
- package/dist/esm/SqlControlRuntime.js +1756 -0
- package/dist/esm/SqlControlRuntime.js.map +1 -0
- package/dist/esm/SqlCredentialStore.d.ts +43 -0
- package/dist/esm/SqlCredentialStore.d.ts.map +1 -0
- package/dist/esm/SqlCredentialStore.js +97 -0
- package/dist/esm/SqlCredentialStore.js.map +1 -0
- package/dist/esm/Steering.d.ts +69 -0
- package/dist/esm/Steering.d.ts.map +1 -0
- package/dist/esm/Steering.js +89 -0
- package/dist/esm/Steering.js.map +1 -0
- package/dist/esm/SystemFlows.d.ts +223 -0
- package/dist/esm/SystemFlows.d.ts.map +1 -0
- package/dist/esm/SystemFlows.js +198 -0
- package/dist/esm/SystemFlows.js.map +1 -0
- package/dist/esm/WebCryptoCipher.d.ts +49 -0
- package/dist/esm/WebCryptoCipher.d.ts.map +1 -0
- package/dist/esm/WebCryptoCipher.js +123 -0
- package/dist/esm/WebCryptoCipher.js.map +1 -0
- package/dist/esm/WebhookChannel.d.ts +113 -0
- package/dist/esm/WebhookChannel.d.ts.map +1 -0
- package/dist/esm/WebhookChannel.js +109 -0
- package/dist/esm/WebhookChannel.js.map +1 -0
- package/dist/esm/index.d.ts +160 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +160 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/MutationBoundary.d.ts +27 -0
- package/dist/esm/internal/MutationBoundary.d.ts.map +1 -0
- package/dist/esm/internal/MutationBoundary.js +40 -0
- package/dist/esm/internal/MutationBoundary.js.map +1 -0
- package/dist/esm/internal/activeFibers.d.ts +12 -0
- package/dist/esm/internal/activeFibers.d.ts.map +1 -0
- package/dist/esm/internal/activeFibers.js +17 -0
- package/dist/esm/internal/activeFibers.js.map +1 -0
- package/dist/esm/internal/issues.d.ts +28 -0
- package/dist/esm/internal/issues.d.ts.map +1 -0
- package/dist/esm/internal/issues.js +35 -0
- package/dist/esm/internal/issues.js.map +1 -0
- package/dist/esm/internal/planning.d.ts +347 -0
- package/dist/esm/internal/planning.d.ts.map +1 -0
- package/dist/esm/internal/planning.js +199 -0
- package/dist/esm/internal/planning.js.map +1 -0
- package/dist/esm/internal/sqlSchemaErrors.d.ts +18 -0
- package/dist/esm/internal/sqlSchemaErrors.d.ts.map +1 -0
- package/dist/esm/internal/sqlSchemaErrors.js +38 -0
- package/dist/esm/internal/sqlSchemaErrors.js.map +1 -0
- package/dist/esm/migrations/0001_control_tables.d.ts +18 -0
- package/dist/esm/migrations/0001_control_tables.d.ts.map +1 -0
- package/dist/esm/migrations/0001_control_tables.js +96 -0
- package/dist/esm/migrations/0001_control_tables.js.map +1 -0
- package/dist/esm/migrations/0002_run_keys.d.ts +15 -0
- package/dist/esm/migrations/0002_run_keys.d.ts.map +1 -0
- package/dist/esm/migrations/0002_run_keys.js +22 -0
- package/dist/esm/migrations/0002_run_keys.js.map +1 -0
- package/dist/esm/migrations/0003_signal_commands.d.ts +13 -0
- package/dist/esm/migrations/0003_signal_commands.d.ts.map +1 -0
- package/dist/esm/migrations/0003_signal_commands.js +26 -0
- package/dist/esm/migrations/0003_signal_commands.js.map +1 -0
- package/dist/esm/migrations/0004_approval_decisions.d.ts +13 -0
- package/dist/esm/migrations/0004_approval_decisions.d.ts.map +1 -0
- package/dist/esm/migrations/0004_approval_decisions.js +25 -0
- package/dist/esm/migrations/0004_approval_decisions.js.map +1 -0
- package/dist/esm/migrations/0005_signal_principals.d.ts +18 -0
- package/dist/esm/migrations/0005_signal_principals.d.ts.map +1 -0
- package/dist/esm/migrations/0005_signal_principals.js +27 -0
- package/dist/esm/migrations/0005_signal_principals.js.map +1 -0
- package/dist/esm/migrations/0006_run_principals.d.ts +18 -0
- package/dist/esm/migrations/0006_run_principals.d.ts.map +1 -0
- package/dist/esm/migrations/0006_run_principals.js +30 -0
- package/dist/esm/migrations/0006_run_principals.js.map +1 -0
- package/dist/esm/migrations/0007_resume_consent.d.ts +18 -0
- package/dist/esm/migrations/0007_resume_consent.d.ts.map +1 -0
- package/dist/esm/migrations/0007_resume_consent.js +27 -0
- package/dist/esm/migrations/0007_resume_consent.js.map +1 -0
- package/dist/esm/test/TestControl.d.ts +19 -0
- package/dist/esm/test/TestControl.d.ts.map +1 -0
- package/dist/esm/test/TestControl.js +30 -0
- package/dist/esm/test/TestControl.js.map +1 -0
- package/docs/README.md +189 -0
- package/docs/api.md +982 -0
- package/docs/concepts/authority.md +109 -0
- package/docs/concepts/cancellation.md +129 -0
- package/docs/concepts/lineage.md +132 -0
- package/docs/concepts/ownership.md +139 -0
- package/docs/concepts/projections.md +203 -0
- package/docs/concepts/receipts.md +128 -0
- package/docs/guides/approvals.md +284 -0
- package/docs/guides/cancel-and-resume.md +162 -0
- package/docs/guides/durable-storage.md +147 -0
- package/docs/guides/implement-an-executor.md +173 -0
- package/docs/guides/ingest-a-webhook.md +177 -0
- package/docs/guides/list-runs.md +160 -0
- package/docs/guides/monitor-runs.md +176 -0
- package/docs/guides/observe-health.md +147 -0
- package/docs/guides/postgres-tests.md +5 -0
- package/docs/guides/serve-over-rpc.md +220 -0
- package/docs/guides/signal-a-run.md +53 -0
- package/docs/guides/steer-a-run.md +138 -0
- package/docs/guides/store-credentials.md +164 -0
- package/docs/guides/testing.md +139 -0
- package/docs/guides/watch-a-run.md +154 -0
- package/docs/installation.md +106 -0
- package/docs/quickstart.md +163 -0
- package/docs/troubleshooting.md +208 -0
- package/package.json +405 -3
- package/src/ApprovalAuthority.ts +114 -0
- package/src/Cancellation.ts +172 -0
- package/src/Channels.ts +493 -0
- package/src/Control.ts +337 -0
- package/src/ControlClient.ts +319 -0
- package/src/ControlError.ts +378 -0
- package/src/ControlExecutor.ts +490 -0
- package/src/ControlFacts.ts +383 -0
- package/src/ControlLive.ts +2113 -0
- package/src/ControlRpcs.ts +443 -0
- package/src/ControlRuntime.ts +1597 -0
- package/src/ControlSchema.ts +1380 -0
- package/src/ControlServer.ts +182 -0
- package/src/Credential.ts +310 -0
- package/src/CredentialCipher.ts +110 -0
- package/src/CredentialStore.ts +152 -0
- package/src/DispatchReader.ts +122 -0
- package/src/Health.ts +591 -0
- package/src/JevSessionChecker.ts +127 -0
- package/src/Lineage.ts +203 -0
- package/src/Migrations.ts +56 -0
- package/src/Monitor.ts +600 -0
- package/src/ScopedToken.ts +306 -0
- package/src/SqlControlRuntime.ts +2476 -0
- package/src/SqlCredentialStore.ts +148 -0
- package/src/Steering.ts +96 -0
- package/src/SystemFlows.ts +225 -0
- package/src/WebCryptoCipher.ts +169 -0
- package/src/WebhookChannel.ts +166 -0
- package/src/index.ts +188 -0
- package/src/internal/MutationBoundary.ts +46 -0
- package/src/internal/activeFibers.ts +22 -0
- package/src/internal/issues.ts +40 -0
- package/src/internal/planning.ts +262 -0
- package/src/internal/sqlSchemaErrors.ts +37 -0
- package/src/migrations/0001_control_tables.ts +99 -0
- package/src/migrations/0002_run_keys.ts +23 -0
- package/src/migrations/0003_signal_commands.ts +27 -0
- package/src/migrations/0004_approval_decisions.ts +25 -0
- package/src/migrations/0005_signal_principals.ts +27 -0
- package/src/migrations/0006_run_principals.ts +31 -0
- package/src/migrations/0007_resume_consent.ts +28 -0
- package/src/test/TestControl.ts +47 -0
|
@@ -0,0 +1,1756 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable `ControlRuntime` over `@smthrs/journal`'s fenced `RunStore` and a
|
|
3
|
+
* SQL database.
|
|
4
|
+
*
|
|
5
|
+
* `layerMemory` models the production seams but keeps everything in a `Map`, so
|
|
6
|
+
* nothing it decides survives the process. This adapter supplies the durable
|
|
7
|
+
* boundary the header of `ControlRuntime` describes.
|
|
8
|
+
*
|
|
9
|
+
* ## Where ownership lives
|
|
10
|
+
*
|
|
11
|
+
* The run lifecycle is not re-implemented here. `RunStore` already owns it, and
|
|
12
|
+
* it is the piece that is hard to get right: every ownership move is a single
|
|
13
|
+
* SQL compare-and-swap, so a stale writer loses the `UPDATE` rather than
|
|
14
|
+
* racing a read-then-write. This module maps the control plane's vocabulary
|
|
15
|
+
* onto it:
|
|
16
|
+
*
|
|
17
|
+
* | control status | `RunStore` status | ownership |
|
|
18
|
+
* | --- | --- | --- |
|
|
19
|
+
* | `accepted`, `running` | `running` | held by this process |
|
|
20
|
+
* | `accepted` after an executor declines | `suspended` | released |
|
|
21
|
+
* | `parked`, `waiting-approval` | `suspended` | released |
|
|
22
|
+
* | `cancelled` / `completed` / `failed` | same | released, terminal |
|
|
23
|
+
*
|
|
24
|
+
* The authoritative `RunSummary` is written into the row's `state_json` by the
|
|
25
|
+
* same fenced `UPDATE` that moves the status, so a projection can never be read
|
|
26
|
+
* out of step with the lifecycle. `RunStore` has no list operation, so
|
|
27
|
+
* `control_runs` is kept as a plain id index and each summary is read back
|
|
28
|
+
* through the store rather than duplicated.
|
|
29
|
+
*
|
|
30
|
+
* ## Fences
|
|
31
|
+
*
|
|
32
|
+
* A fence is a serialized `OwnerId`. `hostId` and `pid` identify the process;
|
|
33
|
+
* the `nonce` is regenerated on **every** claim, so a fence taken before a
|
|
34
|
+
* park is not the fence held after the resume that follows it, and the stale
|
|
35
|
+
* one is refused by the CAS. This is the `rangeID`-style monotonic fence from
|
|
36
|
+
* `reference/temporal`'s history service, narrowed to a per-run token.
|
|
37
|
+
*
|
|
38
|
+
* ## Browser safety
|
|
39
|
+
*
|
|
40
|
+
* Nothing here imports `node:*`. Identity comes from `globalThis.crypto`, and
|
|
41
|
+
* persistence is the driver-neutral SQL contract, so this module runs anywhere
|
|
42
|
+
* `@smthrs/database` has a driver. Fibers are the exception and are
|
|
43
|
+
* deliberately process-local: a fiber is a live continuation, not a row, and
|
|
44
|
+
* cancellation is interruption of the fiber that this process owns.
|
|
45
|
+
*
|
|
46
|
+
* @since 0.1.0
|
|
47
|
+
*/
|
|
48
|
+
import * as Dialect from "@smthrs/database/Dialect";
|
|
49
|
+
import { DurableWriter } from "@smthrs/database/DurableWriter";
|
|
50
|
+
import * as DurableWrites from "@smthrs/database/DurableWriter";
|
|
51
|
+
import { Ownership, RunStore } from "@smthrs/run-store";
|
|
52
|
+
import { Clock, Crypto, Effect, Fiber, Layer, Option, Schema } from "effect";
|
|
53
|
+
import * as SqlClient from "effect/unstable/sql/SqlClient";
|
|
54
|
+
import * as ApprovalAuthority from "./ApprovalAuthority.js";
|
|
55
|
+
import * as Attribution from "./Cancellation.js";
|
|
56
|
+
import { AlreadyResolved, ClaimLost, EnvelopeMismatch, FlowNotFound, InvalidInput, PersistenceError, PlanDenied, PlanDigestMismatch, PlanNotFound, RunNotFound, Unauthorized } from "./ControlError.js";
|
|
57
|
+
import * as ControlExecutor from "./ControlExecutor.js";
|
|
58
|
+
import { ApprovalDecision, ControlRuntime, idPageLimit, make } from "./ControlRuntime.js";
|
|
59
|
+
import { ApprovalTarget, Envelope, GrantScope, PlanCard, PlanDecision, Principal, Receipt, RunSummary, SignalPayload } from "./ControlSchema.js";
|
|
60
|
+
import * as ActiveFibers from "./internal/activeFibers.js";
|
|
61
|
+
import { canonicalIssue, cappedIssue, schemaIssuePath } from "./internal/issues.js";
|
|
62
|
+
import { accepted, adoptedCode, alreadyApplied, budgeted, canonical, codeDriftOf, emptyEnvelope, planCard, planFingerprint, sameEnvelope } from "./internal/planning.js";
|
|
63
|
+
import { causeMessages, missingTable } from "./internal/sqlSchemaErrors.js";
|
|
64
|
+
import * as Lineage from "./Lineage.js";
|
|
65
|
+
import * as Migrations from "./Migrations.js";
|
|
66
|
+
import { plannable } from "./SystemFlows.js";
|
|
67
|
+
const persistence = (operation) => (cause) => new PersistenceError({
|
|
68
|
+
operation,
|
|
69
|
+
message: `Control runtime failed to ${operation}`,
|
|
70
|
+
cause
|
|
71
|
+
});
|
|
72
|
+
const storedFailure = (location, path, reason) => new PersistenceError({
|
|
73
|
+
operation: `decode ${location}`,
|
|
74
|
+
message: `Control runtime could not decode ${location}: ${cappedIssue(path, reason)}`
|
|
75
|
+
});
|
|
76
|
+
const decodeStoredValue = (location, schema, value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError((error) => storedFailure(location, schemaIssuePath(error), "stored value does not match its schema")));
|
|
77
|
+
const decodeStoredJson = (location, schema, json) => Effect.try({
|
|
78
|
+
try: () => JSON.parse(json),
|
|
79
|
+
catch: () => storedFailure(location, "$", "stored value is not valid JSON")
|
|
80
|
+
}).pipe(Effect.flatMap((value) => decodeStoredValue(location, schema, value)));
|
|
81
|
+
/** The admitting principal a signal command recorded, spread onto the command. */
|
|
82
|
+
/** `summary` with the launcher its run's launch index recorded, if any. */
|
|
83
|
+
const withLauncher = (summary, launcher) => launcher === undefined ? summary : { ...summary, launchedBy: { id: launcher.id, kind: launcher.kind } };
|
|
84
|
+
const signalPrincipal = (json) => json === null ? Effect.succeed({}) : Effect.map(decodeStoredJson("control_signal_commands.principal_json", Principal, json), (principal) => ({ principal }));
|
|
85
|
+
/** A random identifier that does not depend on any Node API. */
|
|
86
|
+
const randomId = () => globalThis.crypto.randomUUID();
|
|
87
|
+
const terminal = (status) => status === "cancelled" || status === "completed" || status === "failed";
|
|
88
|
+
/**
|
|
89
|
+
* The deepest nesting the wait walk climbs.
|
|
90
|
+
*
|
|
91
|
+
* It matches `@smthrs/engine-store` `waitingTreeMaxDepth` and exists for the
|
|
92
|
+
* same reason: authored nesting is a handful of executions deep, and the cap
|
|
93
|
+
* bounds the read of a tree whose edges were corrupted outside the engine.
|
|
94
|
+
*/
|
|
95
|
+
const maxWaitTreeDepth = 64;
|
|
96
|
+
/**
|
|
97
|
+
* Whether this database lacks the engine's wait-tree schema.
|
|
98
|
+
*
|
|
99
|
+
* Run-store installs the wait and trampoline columns before the engine
|
|
100
|
+
* installs spawn edges and `execution_flow`. A control-only database can
|
|
101
|
+
* lack the latter without making its ordinary run listings unreadable.
|
|
102
|
+
*/
|
|
103
|
+
const missingWaitTreeSchema = (cause) => missingTable("flows_runs")(cause) || missingTable("flows_run_parents")(cause) ||
|
|
104
|
+
causeMessages(cause).some((message) => message.includes("execution_flow"));
|
|
105
|
+
/**
|
|
106
|
+
* The question a park declared, as JSON, or nothing.
|
|
107
|
+
*
|
|
108
|
+
* The column is the wait's own text under a `json_valid` check, so text that
|
|
109
|
+
* does not parse is text nothing wrote: it reads as a park that declared no
|
|
110
|
+
* question rather than failing the listing it appears in.
|
|
111
|
+
*/
|
|
112
|
+
const declaredRequest = (value) => {
|
|
113
|
+
if (value === null)
|
|
114
|
+
return {};
|
|
115
|
+
try {
|
|
116
|
+
return { request: JSON.parse(value) };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
/** The `RunStore` status the control plane's status projects onto. */
|
|
123
|
+
const storeStatus = (status) => {
|
|
124
|
+
switch (status) {
|
|
125
|
+
case "accepted":
|
|
126
|
+
case "running":
|
|
127
|
+
return "running";
|
|
128
|
+
case "parked":
|
|
129
|
+
case "waiting-approval":
|
|
130
|
+
return "suspended";
|
|
131
|
+
default:
|
|
132
|
+
return status;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Whether two identities are the same *process*.
|
|
137
|
+
*
|
|
138
|
+
* The nonce is deliberately excluded: it is the per-claim fence, so a process
|
|
139
|
+
* that re-claims a run has a new nonce but is still the same owner.
|
|
140
|
+
*/
|
|
141
|
+
const sameProcess = (left, right) => left.hostId === right.hostId && left.pid === right.pid;
|
|
142
|
+
const approvalIdentity = (target) => target._tag === "Plan"
|
|
143
|
+
? { targetTag: target._tag, runId: "", targetId: target.planId }
|
|
144
|
+
: { targetTag: target._tag, runId: target.runId, targetId: target.requestId };
|
|
145
|
+
const sameApprovalIdentity = (left, right) => left._tag === "Plan"
|
|
146
|
+
? right._tag === "Plan" && left.planId === right.planId
|
|
147
|
+
: right._tag === "Node" && left.runId === right.runId && left.requestId === right.requestId;
|
|
148
|
+
const tokenFromRow = (row, target) => Effect.gen(function* () {
|
|
149
|
+
const decision = row.decisionJson === null
|
|
150
|
+
? { _tag: "Pending" }
|
|
151
|
+
: yield* decodeStoredJson("control_tokens.decision_json", ApprovalDecision, row.decisionJson);
|
|
152
|
+
if (row.decisionJson === null && row.resolved === 1) {
|
|
153
|
+
return yield* new PersistenceError({
|
|
154
|
+
operation: "recover an approval decision",
|
|
155
|
+
message: "This legacy approval erased its decision. Preserve the database for review; create a new run and request a new approval. It cannot safely resume through this gate."
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const expectedPrincipal = decision._tag === "Pending" ? undefined : decision.decisionPrincipal;
|
|
159
|
+
const storedPrincipal = row.decisionPrincipalJson === null
|
|
160
|
+
? undefined
|
|
161
|
+
: yield* decodeStoredJson("control_tokens.decision_principal_json", Principal, row.decisionPrincipalJson);
|
|
162
|
+
if (row.resolved !== (decision._tag === "Pending" ? 0 : 1) ||
|
|
163
|
+
JSON.stringify(storedPrincipal) !== JSON.stringify(expectedPrincipal)) {
|
|
164
|
+
return yield* storedFailure("control_tokens.decision_json", "$", "approval decision disagrees with its resolution");
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
tokenId: row.tokenId,
|
|
168
|
+
target,
|
|
169
|
+
...decision
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
/** Plans read per statement while a filtered plan page fills. */
|
|
173
|
+
const planScanBatch = 200;
|
|
174
|
+
const CancelRequestPayload = Schema.Struct({
|
|
175
|
+
principal: Schema.optional(Principal),
|
|
176
|
+
reason: Schema.optional(Schema.String)
|
|
177
|
+
});
|
|
178
|
+
const EngineInterruptionPayload = Schema.Struct({
|
|
179
|
+
outcome: Schema.optional(Schema.String),
|
|
180
|
+
interruptedAtMs: Schema.optional(Schema.Number)
|
|
181
|
+
});
|
|
182
|
+
// Control only projects an engine-owned row's flow name. Importing the full
|
|
183
|
+
// engine state schema would add an engine-store runtime dependency, so this
|
|
184
|
+
// validates exactly the field this package reads and permits the remaining
|
|
185
|
+
// engine-owned fields to pass through the struct decoder unused.
|
|
186
|
+
const EngineStateProjection = Schema.Struct({ flowName: Schema.NonEmptyString });
|
|
187
|
+
/**
|
|
188
|
+
* Creates every control-plane table.
|
|
189
|
+
*
|
|
190
|
+
* `RunStore`'s own migrations are the journal package's business and are
|
|
191
|
+
* applied by its layer. A read-only client installs nothing.
|
|
192
|
+
*
|
|
193
|
+
* @category migrations
|
|
194
|
+
* @since 0.1.0
|
|
195
|
+
*/
|
|
196
|
+
export const migrate = Effect.gen(function* () {
|
|
197
|
+
const sql = yield* SqlClient.SqlClient;
|
|
198
|
+
// A read-only observer reads the schema it finds and installs nothing.
|
|
199
|
+
if (Dialect.isReadOnly(sql))
|
|
200
|
+
return;
|
|
201
|
+
// A standalone runtime cannot record control's high-offset migration first:
|
|
202
|
+
// that high-water mark would make later journal and run-store sets look
|
|
203
|
+
// skipped. The idempotent bootstrap keeps standalone construction safe. The
|
|
204
|
+
// cross-package follow-up is for the host to compose `Migrations.set` beside
|
|
205
|
+
// the journal and run-store sets before it constructs any adapter.
|
|
206
|
+
// Reuse the canonical migration set and the shared transaction/retry policy,
|
|
207
|
+
// without advancing the shared migration ledger during standalone bootstrap.
|
|
208
|
+
yield* DurableWrites.make(sql).write(Effect.forEach(Object.values(Migrations.set.migrations), (migration) => migration, { discard: true }));
|
|
209
|
+
}).pipe(Effect.mapError(persistence("migrate")));
|
|
210
|
+
/**
|
|
211
|
+
* Constructs a durable runtime over the ambient database and run store.
|
|
212
|
+
*
|
|
213
|
+
* Not exported under this name: `make` below is the single public constructor.
|
|
214
|
+
* Exporting both put two names for one function on the package's public
|
|
215
|
+
* surface, and only one of them was documented.
|
|
216
|
+
*/
|
|
217
|
+
const makeRuntime = (options = {}) => {
|
|
218
|
+
// Snapshot before the Effect starts: construction options are caller-owned
|
|
219
|
+
// and may be mutated while migrations or service acquisition are suspended.
|
|
220
|
+
const owner = options.owner === undefined
|
|
221
|
+
? Object.freeze({
|
|
222
|
+
hostId: `control-runtime-${randomId()}`,
|
|
223
|
+
pid: 1,
|
|
224
|
+
nonce: randomId()
|
|
225
|
+
})
|
|
226
|
+
: Object.freeze({ ...options.owner });
|
|
227
|
+
const isAlive = options.isAlive;
|
|
228
|
+
const approvalAuthority = options.approvalAuthority ?? ApprovalAuthority.local;
|
|
229
|
+
const authorizeApproval = approvalAuthority.authorize.bind(approvalAuthority);
|
|
230
|
+
return Effect.gen(function* () {
|
|
231
|
+
const crypto = yield* Crypto.Crypto;
|
|
232
|
+
const writer = yield* DurableWriter;
|
|
233
|
+
const runStore = yield* RunStore.RunStore;
|
|
234
|
+
yield* migrate;
|
|
235
|
+
const sql = yield* Effect.service(SqlClient.SqlClient);
|
|
236
|
+
const configuredFlows = options.flows ?? plannable.map((entry) => ({
|
|
237
|
+
flowId: entry.flowId,
|
|
238
|
+
description: `Reserved ${entry.verb} system flow`,
|
|
239
|
+
deployClass: entry.deployClass,
|
|
240
|
+
envelope: emptyEnvelope
|
|
241
|
+
}));
|
|
242
|
+
const readFlows = (options.loadFlows === undefined
|
|
243
|
+
? Effect.succeed(configuredFlows)
|
|
244
|
+
: Effect.suspend(options.loadFlows)).pipe(Effect.map((entries) => new Map(entries.map((flow) => [flow.flowId, flow]))));
|
|
245
|
+
const readCurrentFlows = options.currentFlows === undefined
|
|
246
|
+
? readFlows
|
|
247
|
+
: Effect.suspend(options.currentFlows).pipe(Effect.map((entries) => new Map(entries.map((flow) => [flow.flowId, flow]))));
|
|
248
|
+
const readAdoptedFlow = (flowId, runId) => options.adoptFlow === undefined
|
|
249
|
+
? readCurrentFlows.pipe(Effect.map((flows) => flows.get(flowId)))
|
|
250
|
+
: Effect.suspend(() => options.adoptFlow(flowId, runId));
|
|
251
|
+
// Fibers are live continuations, not rows. A restarted process legitimately
|
|
252
|
+
// has none, and interrupting a run it does not own is the other process's
|
|
253
|
+
// job — so this map is process-local by design, not by omission.
|
|
254
|
+
const fibers = new Map();
|
|
255
|
+
let pendingSignalCursor = 0;
|
|
256
|
+
const now = Clock.currentTimeMillis;
|
|
257
|
+
const query = (operation) => (effect) => effect.pipe(Effect.mapError(persistence(operation)));
|
|
258
|
+
/**
|
|
259
|
+
* Runs a read that may fail on a missing table or column, and is caught.
|
|
260
|
+
*
|
|
261
|
+
* Inside an enclosing transaction the read takes a savepoint, because a
|
|
262
|
+
* failed statement aborts a PostgreSQL transaction and the catch could not
|
|
263
|
+
* recover it. Outside one it runs bare: on SQLite a top-level transaction
|
|
264
|
+
* is `BEGIN IMMEDIATE`, which takes the write lock for a read and fails a
|
|
265
|
+
* contender with `SQLITE_BUSY` while another plane holds the claim.
|
|
266
|
+
*/
|
|
267
|
+
const probe = (effect) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (enclosing) => Option.isSome(enclosing) ? sql.withTransaction(effect) : effect);
|
|
268
|
+
/** Allocates the next value of a durable counter inside one transaction. */
|
|
269
|
+
const nextSequence = (name) => writer.write(Effect.gen(function* () {
|
|
270
|
+
yield* sql `INSERT INTO control_sequences (name, value) VALUES (${name}, 0) ON CONFLICT (name) DO NOTHING`;
|
|
271
|
+
const rows = yield* sql `
|
|
272
|
+
UPDATE control_sequences SET value = value + 1
|
|
273
|
+
WHERE name = ${name} AND value < ${Number.MAX_SAFE_INTEGER} RETURNING value
|
|
274
|
+
`;
|
|
275
|
+
const value = Number(rows[0]?.value);
|
|
276
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
277
|
+
return yield* Effect.fail(new Error(`Sequence ${name} did not return a positive safe integer`));
|
|
278
|
+
}
|
|
279
|
+
return value;
|
|
280
|
+
})).pipe(Effect.mapError(persistence("allocate a sequence")));
|
|
281
|
+
const readPlan = (planId) => sql `
|
|
282
|
+
SELECT plan_id AS "planId", card_json AS "cardJson",
|
|
283
|
+
decoded_input_json AS "decodedInputJson", decision
|
|
284
|
+
FROM control_plans WHERE plan_id = ${planId}
|
|
285
|
+
`.pipe(query("read a plan"), Effect.map((rows) => Option.fromNullishOr(rows[0])));
|
|
286
|
+
const storedPlan = (row) => Effect.all({
|
|
287
|
+
card: decodeStoredJson("control_plans.card_json", PlanCard, row.cardJson),
|
|
288
|
+
decodedInput: decodeStoredJson("control_plans.decoded_input_json", Schema.Json, row.decodedInputJson),
|
|
289
|
+
decision: decodeStoredValue("control_plans.decision", PlanDecision, row.decision)
|
|
290
|
+
});
|
|
291
|
+
const requirePlan = (planId) => Effect.flatMap(readPlan(planId), Option.match({
|
|
292
|
+
onNone: () => Effect.fail(new PlanNotFound({ planId })),
|
|
293
|
+
onSome: Effect.succeed
|
|
294
|
+
}));
|
|
295
|
+
/**
|
|
296
|
+
* Reads a run row, translating a missing row into `RunNotFound` and every
|
|
297
|
+
* other store failure into `PersistenceError` — never a defect.
|
|
298
|
+
*/
|
|
299
|
+
const requireRow = (runId) => runStore.get(runId).pipe(Effect.mapError((error) => error.code === "not_found_row"
|
|
300
|
+
? new RunNotFound({ runId })
|
|
301
|
+
: persistence("read a run")(error)));
|
|
302
|
+
/**
|
|
303
|
+
* The control status a store status projects back onto.
|
|
304
|
+
*
|
|
305
|
+
* The forward map is lossy — `accepted` and `running` both store as
|
|
306
|
+
* `running` — so a run this plane did not launch is reported under the
|
|
307
|
+
* status the store can actually prove.
|
|
308
|
+
*/
|
|
309
|
+
const controlStatus = (status) => {
|
|
310
|
+
switch (status) {
|
|
311
|
+
case "pending":
|
|
312
|
+
return "accepted";
|
|
313
|
+
case "running":
|
|
314
|
+
return "running";
|
|
315
|
+
case "suspended":
|
|
316
|
+
return "parked";
|
|
317
|
+
default:
|
|
318
|
+
return status;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Decodes one state row once, then validates the projection its keys name.
|
|
323
|
+
* A control summary and an engine state share the column but not a schema.
|
|
324
|
+
*/
|
|
325
|
+
const decodeRunState = (stateJson) => Effect.gen(function* () {
|
|
326
|
+
const parsed = yield* decodeStoredJson("flows_runs.state_json", Schema.Json, stateJson);
|
|
327
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
328
|
+
const candidate = parsed;
|
|
329
|
+
if ("runId" in candidate || "flowId" in candidate || "status" in candidate) {
|
|
330
|
+
return {
|
|
331
|
+
_tag: "Control",
|
|
332
|
+
summary: yield* decodeStoredValue("flows_runs.state_json as RunSummary", RunSummary, parsed)
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const engine = yield* decodeStoredValue("flows_runs.state_json as engine state", EngineStateProjection, parsed);
|
|
337
|
+
return { _tag: "Engine", flowName: engine.flowName };
|
|
338
|
+
});
|
|
339
|
+
const optional = (value) => value === null || value === undefined ? {} : { value };
|
|
340
|
+
/** `WHERE` material narrowing a column to a scope. */
|
|
341
|
+
const within = (column, scope) => sql.in(column, scope);
|
|
342
|
+
/**
|
|
343
|
+
* Projects a run row onto a control summary, ancestry included.
|
|
344
|
+
*
|
|
345
|
+
* Ancestry reaches the row from two different places, because the engine
|
|
346
|
+
* records two different relationships. `parent_run_id` is the trampoline
|
|
347
|
+
* chain — the round before this one — and it is the only ancestry a run
|
|
348
|
+
* row carries. A run another run SPAWNED records nothing in its own row:
|
|
349
|
+
* the edge lives in `flows_run_parents`, which is the subflow DAG cycle
|
|
350
|
+
* detection walks (`packages/smithers/flows/run-store/src/migrations/0002_lineage.ts`).
|
|
351
|
+
* A projection that read the column alone would report every child of
|
|
352
|
+
* every run as an orphan.
|
|
353
|
+
*
|
|
354
|
+
* The column wins when both exist, which is the case for round 1 of a run
|
|
355
|
+
* that was itself spawned: the round's nearest ancestor is the round
|
|
356
|
+
* before it, not the run that spawned round 0.
|
|
357
|
+
*
|
|
358
|
+
* @param row the run row
|
|
359
|
+
* @param ancestry the fork markers and spawn edges of the whole database
|
|
360
|
+
*/
|
|
361
|
+
const baseSummary = (row, state) => state._tag === "Control"
|
|
362
|
+
? state.summary
|
|
363
|
+
: {
|
|
364
|
+
runId: row.runId,
|
|
365
|
+
flowId: state.flowName,
|
|
366
|
+
status: controlStatus(row.status),
|
|
367
|
+
createdAt: row.createdAtMs,
|
|
368
|
+
updatedAt: row.finishedAtMs ?? row.startedAtMs ?? row.createdAtMs
|
|
369
|
+
};
|
|
370
|
+
const summaryFrom = (row, ancestry) => Effect.gen(function* () {
|
|
371
|
+
const base = withLauncher(baseSummary(row, yield* decodeRunState(row.stateJson)), yield* launcherOf(row.runId));
|
|
372
|
+
const parentRunId = optional(row.parentRunId).value ?? ancestry.spawnedBy.get(row.runId);
|
|
373
|
+
const lineageId = optional(row.lineageId).value;
|
|
374
|
+
const roundOrdinal = optional(row.roundOrdinal).value;
|
|
375
|
+
const origin = Lineage.originOf({
|
|
376
|
+
...(parentRunId === undefined ? {} : { parentRunId }),
|
|
377
|
+
...(roundOrdinal === undefined ? {} : { roundOrdinal }),
|
|
378
|
+
forked: ancestry.forked.has(row.runId)
|
|
379
|
+
});
|
|
380
|
+
const waitingReason = ancestry.waitingFor.get(row.runId);
|
|
381
|
+
const cancellation = ancestry.cancellations.get(row.runId);
|
|
382
|
+
const pendingResume = ancestry.pendingResumes.get(row.runId);
|
|
383
|
+
const pendingWaits = ancestry.humanWaits.get(row.runId);
|
|
384
|
+
return {
|
|
385
|
+
...base,
|
|
386
|
+
// A run whose tree holds an open human wait is waiting on a human,
|
|
387
|
+
// however nested the row that holds it and whether or not its own row
|
|
388
|
+
// has flipped to parked yet: a parent awaiting a `.child()` is
|
|
389
|
+
// blocked on that child's question either way. Rolling the status up
|
|
390
|
+
// is what lets every existing `status: "waiting-approval"` filter —
|
|
391
|
+
// the gateway inbox, `smithers approvals list`, the diagnosis card —
|
|
392
|
+
// find a `HumanTask` parked on a descendant.
|
|
393
|
+
//
|
|
394
|
+
// Only ATTACHED waits reach here. A `detach` spawn outlives the run
|
|
395
|
+
// that started it (`@smthrs/engine-store` `RunState.onParentExit`),
|
|
396
|
+
// so the walk stops at one rather than telling a reader that a run
|
|
397
|
+
// which can proceed cannot.
|
|
398
|
+
...(pendingWaits === undefined ? {} : { pendingWaits }),
|
|
399
|
+
...(pendingWaits === undefined || !pendingWaits.some((wait) => wait.reason === ControlExecutor.humanWaitReason) || terminal(base.status)
|
|
400
|
+
? {}
|
|
401
|
+
: { status: "waiting-approval" }),
|
|
402
|
+
...(pendingResume === undefined ? {} : { pendingResume }),
|
|
403
|
+
...(parentRunId === undefined ? {} : { parentRunId }),
|
|
404
|
+
...(lineageId === undefined ? {} : { lineageId }),
|
|
405
|
+
...(roundOrdinal === undefined ? {} : { roundOrdinal }),
|
|
406
|
+
...(origin === undefined ? {} : { origin }),
|
|
407
|
+
...(waitingReason === undefined ? {} : { waitingReason }),
|
|
408
|
+
...(cancellation === undefined ? {} : { cancellation })
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
/**
|
|
412
|
+
* The runs a `fork-created` marker names.
|
|
413
|
+
*
|
|
414
|
+
* Time travel writes the marker on the forked child's own journal, which
|
|
415
|
+
* is the only evidence separating a fork from an ordinary child: both
|
|
416
|
+
* record `parent_run_id`. A composition whose journal is not this database
|
|
417
|
+
* has no journal table here at all, and the honest answer there is "no
|
|
418
|
+
* fork evidence" — not a failed projection — so exactly that one failure
|
|
419
|
+
* is folded into the empty set.
|
|
420
|
+
*
|
|
421
|
+
* Every other failure is reported. A locked database, a corrupt page, or
|
|
422
|
+
* a table that exists but no longer answers this question would otherwise
|
|
423
|
+
* report every fork in the deployment as an ordinary child, silently and
|
|
424
|
+
* for as long as the condition lasted.
|
|
425
|
+
*/
|
|
426
|
+
const forkedRunIds = (scope) => sql `
|
|
427
|
+
SELECT DISTINCT run_id AS "runId" FROM flows_journal_events
|
|
428
|
+
WHERE event_type = ${Lineage.forkCreatedEventType} AND ${within("run_id", scope)}
|
|
429
|
+
`.pipe(Effect.map((rows) => new Set(rows.map((row) => row.runId))), Effect.catchIf(missingTable("flows_journal_events"), () => Effect.succeed(new Set())), Effect.mapError(persistence("read fork markers")));
|
|
430
|
+
/**
|
|
431
|
+
* The run that spawned each child, by child id.
|
|
432
|
+
*
|
|
433
|
+
* `seq` is the engine's store-global insertion order, so the FIRST edge is
|
|
434
|
+
* the creating parent. A diamond's later parents are edges too, and a
|
|
435
|
+
* summary names one ancestor, so the creating one is the one it names.
|
|
436
|
+
*
|
|
437
|
+
* Missing table, missing evidence, exactly as with the fork markers: a
|
|
438
|
+
* control plane over a database with no engine state in it observes runs
|
|
439
|
+
* that spawned nothing.
|
|
440
|
+
*/
|
|
441
|
+
const spawnedBy = (scope) => sql `
|
|
442
|
+
SELECT child_id AS "childId", parent_id AS "parentId"
|
|
443
|
+
FROM flows_run_parents WHERE ${within("child_id", scope)} ORDER BY seq DESC
|
|
444
|
+
`.pipe(probe,
|
|
445
|
+
// Descending, so the lowest `seq` is written last and wins the key.
|
|
446
|
+
Effect.map((rows) => new Map(rows.map((row) => [row.childId, row.parentId]))), Effect.catchIf(missingTable("flows_run_parents"), () => Effect.succeed(new Map())), Effect.mapError(persistence("read spawn edges")));
|
|
447
|
+
/**
|
|
448
|
+
* What each parked run is waiting for, by run id.
|
|
449
|
+
*
|
|
450
|
+
* The engine writes `waiting_reason` on the run row when it parks a run
|
|
451
|
+
* (`packages/smithers/flows/engine-store/src/DurableEngineState.ts` `park`), and clears
|
|
452
|
+
* it on the wake. The control plane reads it and never writes it: a park
|
|
453
|
+
* belongs to whoever is holding the run, and the projection reports the
|
|
454
|
+
* hold rather than deciding it.
|
|
455
|
+
*
|
|
456
|
+
* The reason separates the parks a steer can end from the parks it
|
|
457
|
+
* cannot, so `Control.steer` needs it on the summary and not only in the
|
|
458
|
+
* engine's own store.
|
|
459
|
+
*/
|
|
460
|
+
const waitingFor = (scope) => sql `
|
|
461
|
+
SELECT run_id AS "runId", waiting_reason AS "waitingReason"
|
|
462
|
+
FROM flows_runs WHERE waiting_reason IS NOT NULL AND ${within("run_id", scope)}
|
|
463
|
+
`.pipe(Effect.map((rows) => new Map(rows.map((row) => [row.runId, row.waitingReason]))), Effect.mapError(persistence("read waiting reasons")));
|
|
464
|
+
/**
|
|
465
|
+
* Open human waits in each run's tree, by every run that contains one.
|
|
466
|
+
*
|
|
467
|
+
* The walk goes UP, not down. Approval parks are rare and run trees are
|
|
468
|
+
* shallow, so start from the parked rows and climb every spawn and
|
|
469
|
+
* trampoline edge; starting from every run in scope and descending would
|
|
470
|
+
* re-walk the whole forest to find the same few rows. A wait therefore
|
|
471
|
+
* appears under its own execution AND under every ancestor of it, which
|
|
472
|
+
* is exactly what "does this run tree owe anybody an answer" asks.
|
|
473
|
+
*
|
|
474
|
+
* The scope filters the ANCESTOR, not the parked row: a listing wants the
|
|
475
|
+
* waits of the runs it is about to return, wherever those waits are held.
|
|
476
|
+
*
|
|
477
|
+
* Collapse paths to each wait/ancestor pair at their shortest depth so
|
|
478
|
+
* shared descendants and cycles cannot duplicate or reorder a wait.
|
|
479
|
+
* A database without the engine's wait-tree schema observes no nested
|
|
480
|
+
* waits rather than failing every listing.
|
|
481
|
+
*/
|
|
482
|
+
const humanWaits = (scope) => sql `
|
|
483
|
+
WITH RECURSIVE human_waits(wait_run_id, ancestor_id, depth) AS (
|
|
484
|
+
SELECT run_id, run_id, 0 FROM flows_runs
|
|
485
|
+
WHERE waiting_reason IN (${ControlExecutor.humanWaitReason}, 'event')
|
|
486
|
+
AND waiting_token IS NOT NULL
|
|
487
|
+
AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
488
|
+
UNION
|
|
489
|
+
SELECT human_waits.wait_run_id, parent.run_id, human_waits.depth + 1
|
|
490
|
+
FROM flows_runs step JOIN human_waits ON step.run_id = human_waits.ancestor_id
|
|
491
|
+
JOIN flows_runs parent ON parent.run_id IN (
|
|
492
|
+
SELECT parent_id FROM flows_run_parents WHERE child_id = step.run_id
|
|
493
|
+
UNION ALL
|
|
494
|
+
SELECT step.parent_run_id WHERE step.parent_run_id IS NOT NULL
|
|
495
|
+
)
|
|
496
|
+
WHERE human_waits.depth < ${maxWaitTreeDepth}
|
|
497
|
+
AND (parent.run_id = step.parent_run_id
|
|
498
|
+
OR COALESCE(${Dialect.jsonText(sql, sql `step.state_json`, "$.onParentExit")}, 'cancel') <> 'detach')
|
|
499
|
+
), reachable(wait_run_id, ancestor_id, depth) AS (
|
|
500
|
+
SELECT wait_run_id, ancestor_id, MIN(depth) FROM human_waits GROUP BY wait_run_id, ancestor_id
|
|
501
|
+
)
|
|
502
|
+
SELECT
|
|
503
|
+
reachable.ancestor_id AS "ancestorId",
|
|
504
|
+
reachable.depth AS "depth",
|
|
505
|
+
parked.run_id AS "runId",
|
|
506
|
+
parked.execution_flow AS "flowId",
|
|
507
|
+
parked.waiting_reason AS "waitingReason",
|
|
508
|
+
parked.waiting_token AS "waitingToken",
|
|
509
|
+
parked.waiting_request AS "waitingRequest",
|
|
510
|
+
parked.created_at_ms AS "createdAtMs"
|
|
511
|
+
FROM reachable JOIN flows_runs parked ON parked.run_id = reachable.wait_run_id
|
|
512
|
+
WHERE ${within("reachable.ancestor_id", scope)}
|
|
513
|
+
ORDER BY reachable.ancestor_id, reachable.depth, parked.created_at_ms, parked.run_id
|
|
514
|
+
`.pipe(probe, Effect.map((rows) => {
|
|
515
|
+
const index = new Map();
|
|
516
|
+
for (const row of rows) {
|
|
517
|
+
const built = ControlExecutor.pendingWaitOf({
|
|
518
|
+
runId: row.runId,
|
|
519
|
+
reason: row.waitingReason,
|
|
520
|
+
token: row.waitingToken,
|
|
521
|
+
createdAt: row.createdAtMs,
|
|
522
|
+
...(row.flowId === null ? {} : { flowId: row.flowId }),
|
|
523
|
+
...declaredRequest(row.waitingRequest)
|
|
524
|
+
});
|
|
525
|
+
if (built === undefined)
|
|
526
|
+
continue;
|
|
527
|
+
const waits = index.get(row.ancestorId) ?? [];
|
|
528
|
+
waits.push(built);
|
|
529
|
+
index.set(row.ancestorId, waits);
|
|
530
|
+
}
|
|
531
|
+
return index;
|
|
532
|
+
}), Effect.catchIf(missingWaitTreeSchema, () => Effect.succeed(new Map())), Effect.mapError(persistence("read nested human waits")));
|
|
533
|
+
/**
|
|
534
|
+
* The attributed cancel requests this plane journaled, by run id.
|
|
535
|
+
*
|
|
536
|
+
* The FIRST entry for a run wins. A cancel is idempotent, so a repeat asks
|
|
537
|
+
* for something that already happened; the request that caused the
|
|
538
|
+
* cancellation is the one that gets to name the principal and the reason.
|
|
539
|
+
*/
|
|
540
|
+
const cancelRequests = (scope) => sql `
|
|
541
|
+
SELECT run_id AS "runId", emitted_at_ms AS "emittedAtMs", payload_json AS "payloadJson"
|
|
542
|
+
FROM flows_journal_events
|
|
543
|
+
WHERE event_type = ${Attribution.requestedEventType} AND ${within("run_id", scope)}
|
|
544
|
+
ORDER BY run_id, seq
|
|
545
|
+
`.pipe(Effect.catchIf(missingTable("flows_journal_events"), () => Effect.succeed(new Array())), Effect.mapError(persistence("read cancel requests")), Effect.flatMap((rows) => Effect.gen(function* () {
|
|
546
|
+
const requests = new Map();
|
|
547
|
+
for (const row of rows) {
|
|
548
|
+
if (requests.has(row.runId))
|
|
549
|
+
continue;
|
|
550
|
+
const payload = yield* decodeStoredJson("flows_journal_events.payload_json for control.run.cancel-requested", CancelRequestPayload, row.payloadJson);
|
|
551
|
+
requests.set(row.runId, {
|
|
552
|
+
requestedAt: Number(row.emittedAtMs),
|
|
553
|
+
...(payload.principal === undefined ? {} : { principal: payload.principal }),
|
|
554
|
+
...(payload.reason === undefined ? {} : { reason: payload.reason })
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return requests;
|
|
558
|
+
})));
|
|
559
|
+
/**
|
|
560
|
+
* When the engine journaled each run's interruption.
|
|
561
|
+
*
|
|
562
|
+
* The engine writes this record in the same transaction as the `cancelled`
|
|
563
|
+
* transition (`packages/smithers/flows/engine-store/src/internal/RunDriver.ts`), so it is
|
|
564
|
+
* the moment a cancellation actually took, as opposed to the moment
|
|
565
|
+
* somebody asked. A run cancelled by a peer process that never wrote a
|
|
566
|
+
* request column still has this.
|
|
567
|
+
*/
|
|
568
|
+
const engineInterruptions = (scope) => sql `
|
|
569
|
+
SELECT run_id AS "runId", payload_json AS "payloadJson"
|
|
570
|
+
FROM flows_journal_events
|
|
571
|
+
WHERE event_type = ${Attribution.interruptedEventType} AND ${within("run_id", scope)}
|
|
572
|
+
`.pipe(Effect.catchIf(missingTable("flows_journal_events"), () => Effect.succeed(new Array())), Effect.mapError(persistence("read engine interruptions")), Effect.flatMap((rows) => Effect.gen(function* () {
|
|
573
|
+
const cancelled = new Map();
|
|
574
|
+
for (const row of rows) {
|
|
575
|
+
const payload = yield* decodeStoredJson("flows_journal_events.payload_json for flows.engine.interrupted", EngineInterruptionPayload, row.payloadJson);
|
|
576
|
+
if (payload.outcome !== "cancelled")
|
|
577
|
+
continue;
|
|
578
|
+
cancelled.set(row.runId, Number(payload.interruptedAtMs ?? 0));
|
|
579
|
+
}
|
|
580
|
+
return cancelled;
|
|
581
|
+
})));
|
|
582
|
+
/**
|
|
583
|
+
* The ancestry and cancel columns of every run row.
|
|
584
|
+
*
|
|
585
|
+
* Cascade is a fact about a run's ancestors, so the attribution cannot be
|
|
586
|
+
* decided a row at a time: the request that cancelled a child may be three
|
|
587
|
+
* rounds up the chain.
|
|
588
|
+
*/
|
|
589
|
+
const cancelEvidence = (scope) => sql `
|
|
590
|
+
SELECT run_id AS "runId", parent_run_id AS "parentRunId",
|
|
591
|
+
cancel_requested_at_ms AS "cancelRequestedAtMs"
|
|
592
|
+
FROM flows_runs WHERE ${within("run_id", scope)}
|
|
593
|
+
`.pipe(Effect.mapError(persistence("read cancel evidence")));
|
|
594
|
+
/** Every cancelled run's attribution in the scope, folded in one pass. */
|
|
595
|
+
const cancellations = (scope) => Effect.map(Effect.all({
|
|
596
|
+
rows: cancelEvidence(scope),
|
|
597
|
+
requests: cancelRequests(scope),
|
|
598
|
+
interrupted: engineInterruptions(scope),
|
|
599
|
+
spawnedBy: spawnedBy(scope)
|
|
600
|
+
}), ({ interrupted, requests, rows, spawnedBy }) => Attribution.attribute({
|
|
601
|
+
runs: rows.map((row) => {
|
|
602
|
+
const parentRunId = row.parentRunId ?? spawnedBy.get(row.runId);
|
|
603
|
+
const cancelledAt = interrupted.get(row.runId);
|
|
604
|
+
return {
|
|
605
|
+
runId: row.runId,
|
|
606
|
+
...(parentRunId === undefined || parentRunId === null ? {} : { parentRunId }),
|
|
607
|
+
...(row.cancelRequestedAtMs === null ? {} : { cancelRequestedAt: Number(row.cancelRequestedAtMs) }),
|
|
608
|
+
...(cancelledAt === undefined ? {} : { cancelledAt })
|
|
609
|
+
};
|
|
610
|
+
}),
|
|
611
|
+
requests
|
|
612
|
+
}));
|
|
613
|
+
/**
|
|
614
|
+
* The outstanding resume delegation of each run in the scope.
|
|
615
|
+
*
|
|
616
|
+
* Read from `control_run_resumes` rather than from the journal because the
|
|
617
|
+
* question is "what has not been taken up yet", which a log of what was
|
|
618
|
+
* asked cannot answer without a per-run cursor.
|
|
619
|
+
*/
|
|
620
|
+
const pendingResumeIndex = (scope) => sql `
|
|
621
|
+
SELECT run_id AS "runId", requested_seq AS "requestedSeq"
|
|
622
|
+
FROM control_run_resumes WHERE ${within("run_id", scope)}
|
|
623
|
+
`.pipe(Effect.map((rows) => new Map(rows.map((row) => [row.runId, Number(row.requestedSeq)]))), Effect.mapError(persistence("read pending resumes")));
|
|
624
|
+
/** Every index a projection needs over one scope, read together. */
|
|
625
|
+
const ancestryIndex = (scope) => Effect.map(Effect.all({
|
|
626
|
+
forked: forkedRunIds(scope),
|
|
627
|
+
spawnedBy: spawnedBy(scope),
|
|
628
|
+
waitingFor: waitingFor(scope),
|
|
629
|
+
humanWaits: humanWaits(scope),
|
|
630
|
+
cancellations: cancellations(scope),
|
|
631
|
+
pendingResumes: pendingResumeIndex(scope)
|
|
632
|
+
}), (index) => index);
|
|
633
|
+
/**
|
|
634
|
+
* One run and every ancestor above it, nearest first.
|
|
635
|
+
*
|
|
636
|
+
* The trampoline chain is one recursive read over `parent_run_id`. A
|
|
637
|
+
* SPAWNED run records nothing in its own row, so when a chain runs out the
|
|
638
|
+
* spawn edge is looked up and the walk continues from there — one extra
|
|
639
|
+
* read per nesting level, and subflow nesting is shallow where a
|
|
640
|
+
* trampoline is long. The visited set makes corrupt ancestry terminate
|
|
641
|
+
* instead of taking the control plane down with it.
|
|
642
|
+
*/
|
|
643
|
+
const ancestorChain = (runId) => Effect.gen(function* () {
|
|
644
|
+
const chain = [];
|
|
645
|
+
const visited = new Set();
|
|
646
|
+
let start = runId;
|
|
647
|
+
while (start !== undefined && !visited.has(start)) {
|
|
648
|
+
const rows = yield* sql `
|
|
649
|
+
WITH RECURSIVE ancestry(run_id, parent_run_id) AS (
|
|
650
|
+
SELECT run_id, parent_run_id FROM flows_runs WHERE run_id = ${start}
|
|
651
|
+
UNION
|
|
652
|
+
SELECT runs.run_id, runs.parent_run_id
|
|
653
|
+
FROM flows_runs runs JOIN ancestry ON runs.run_id = ancestry.parent_run_id
|
|
654
|
+
)
|
|
655
|
+
SELECT run_id AS "runId", parent_run_id AS "parentRunId" FROM ancestry
|
|
656
|
+
`.pipe(Effect.mapError(persistence("walk a run's ancestry")));
|
|
657
|
+
if (rows.length === 0) {
|
|
658
|
+
// No row at all: the caller's own `requireRow` reports that.
|
|
659
|
+
chain.push(start);
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
let last;
|
|
663
|
+
for (const row of rows) {
|
|
664
|
+
if (visited.has(row.runId))
|
|
665
|
+
continue;
|
|
666
|
+
visited.add(row.runId);
|
|
667
|
+
chain.push(row.runId);
|
|
668
|
+
if (row.parentRunId === null)
|
|
669
|
+
last = row.runId;
|
|
670
|
+
}
|
|
671
|
+
// The chain ended at a row naming no parent. A run somebody SPAWNED
|
|
672
|
+
// records its parent in the edge table instead, so the walk
|
|
673
|
+
// continues from there.
|
|
674
|
+
const spawn = last === undefined ? undefined : (yield* spawnedBy([last])).get(last);
|
|
675
|
+
start = spawn;
|
|
676
|
+
}
|
|
677
|
+
return chain;
|
|
678
|
+
});
|
|
679
|
+
/**
|
|
680
|
+
* One run row as a control summary, whoever wrote the row.
|
|
681
|
+
*
|
|
682
|
+
* `flows_runs.state_json` is `@smthrs/run-store`'s column and it has two
|
|
683
|
+
* writers: this plane stores a `RunSummary` there, and the engine stores
|
|
684
|
+
* its own `RunState` (`version`, `flowName`, `payload`). Decoding the
|
|
685
|
+
* column as a `RunSummary` outright therefore failed
|
|
686
|
+
* `PersistenceError` for every engine-created run before the caller could
|
|
687
|
+
* reach the answer it was owed: `interrupt` reads the summary before it
|
|
688
|
+
* asks who owns the row, so cancelling a run the ENGINE owns reported a
|
|
689
|
+
* corrupt database instead of the `ClaimLost` that lets `Control.cancel`
|
|
690
|
+
* fall back to the durable request, and `resume` failed the same way
|
|
691
|
+
* before it could delegate to the owning driver.
|
|
692
|
+
*
|
|
693
|
+
* `baseSummary` already projects both shapes for listings. Sharing it here
|
|
694
|
+
* keeps one answer for one row: a control row decodes to the summary it
|
|
695
|
+
* stored, and an engine row is projected from the columns the run store
|
|
696
|
+
* owns. Nothing writes back through this path except a transition this
|
|
697
|
+
* plane's own fence authorized, which is a control row by construction.
|
|
698
|
+
*/
|
|
699
|
+
const summaryOf = (row) => Effect.zipWith(decodeRunState(row.stateJson), launcherOf(row.runId), (state, launcher) => withLauncher(baseSummary(row, state), launcher));
|
|
700
|
+
const snapshotOf = (row) => ({
|
|
701
|
+
status: row.status,
|
|
702
|
+
owner: row.owner,
|
|
703
|
+
heartbeatAtMs: row.heartbeatAtMs
|
|
704
|
+
});
|
|
705
|
+
// A type predicate, not a plain boolean: a row this process owns has a
|
|
706
|
+
// non-null owner by construction, and the callers hand `row.owner` straight
|
|
707
|
+
// to the fenced transitions.
|
|
708
|
+
const ownedByUs = (row) => row.status === "running" && row.owner !== null && sameProcess(row.owner, owner);
|
|
709
|
+
/**
|
|
710
|
+
* Moves a run this process owns to a new control status, writing the
|
|
711
|
+
* projection in the same compare-and-swap. A lost fence is `ClaimLost`.
|
|
712
|
+
*/
|
|
713
|
+
const transition = (runId, claim, summary, status) => Effect.gen(function* () {
|
|
714
|
+
const timestamp = yield* now;
|
|
715
|
+
const next = {
|
|
716
|
+
...summary,
|
|
717
|
+
status,
|
|
718
|
+
updatedAt: timestamp,
|
|
719
|
+
...(storeStatus(status) === "running" ? {} : { ownerId: undefined }),
|
|
720
|
+
// A park releases the owner columns, so the row itself stops saying
|
|
721
|
+
// which process is hosting the execution. The fence it was parked
|
|
722
|
+
// under is kept instead: it is what lets the host recognize its own
|
|
723
|
+
// park, and every other process tell that the execution belongs to
|
|
724
|
+
// one it cannot see (triage B-15). Any other status ends the park,
|
|
725
|
+
// so it ends the record with it.
|
|
726
|
+
parkedBy: storeStatus(status) === "suspended" ? JSON.stringify(claim) : undefined
|
|
727
|
+
};
|
|
728
|
+
const outcome = yield* runStore.transitionOwned(runId, claim, storeStatus(status), JSON.stringify(next)).pipe(Effect.mapError(persistence("transition a run")));
|
|
729
|
+
if (outcome._tag === "NotFound")
|
|
730
|
+
return yield* Effect.fail(new RunNotFound({ runId }));
|
|
731
|
+
if (outcome._tag !== "Transitioned")
|
|
732
|
+
return yield* Effect.fail(new ClaimLost({ runId }));
|
|
733
|
+
return next;
|
|
734
|
+
});
|
|
735
|
+
/**
|
|
736
|
+
* Evidence that a running row's owner is gone, from the configured
|
|
737
|
+
* liveness check, or `undefined` when there is no check or the owner is
|
|
738
|
+
* alive. A pid is evidence only on its own host; elsewhere the claim
|
|
739
|
+
* rests on the expired lease the run store verifies.
|
|
740
|
+
*/
|
|
741
|
+
const deadOwner = (row) => Effect.gen(function* () {
|
|
742
|
+
if (isAlive === undefined || row.owner === null)
|
|
743
|
+
return undefined;
|
|
744
|
+
const nowMs = yield* now;
|
|
745
|
+
const alive = yield* isAlive(row.owner, { claimant: owner, heartbeatAtMs: row.heartbeatAtMs, nowMs });
|
|
746
|
+
if (alive)
|
|
747
|
+
return undefined;
|
|
748
|
+
return {
|
|
749
|
+
expectedOwner: row.owner,
|
|
750
|
+
checkedAtMs: nowMs,
|
|
751
|
+
kind: Ownership.sameHostIncarnation(row.owner, owner)
|
|
752
|
+
? "same-host-pid-dead"
|
|
753
|
+
: "lease-expired"
|
|
754
|
+
};
|
|
755
|
+
});
|
|
756
|
+
/**
|
|
757
|
+
* Takes ownership of a suspended or pending run under a fresh nonce, or
|
|
758
|
+
* of a running one whose owner `evidence` says is gone.
|
|
759
|
+
*/
|
|
760
|
+
const claim = (runId, row, evidence, adopted) => Effect.gen(function* () {
|
|
761
|
+
const timestamp = evidence?.checkedAtMs ?? (yield* now);
|
|
762
|
+
const claimant = { ...owner, nonce: randomId() };
|
|
763
|
+
const outcome = yield* runStore.claimAndOwn(runId, snapshotOf(row), claimant, timestamp, evidence).pipe(Effect.mapError(persistence("claim a run")));
|
|
764
|
+
if (outcome._tag === "NotFound")
|
|
765
|
+
return yield* Effect.fail(new RunNotFound({ runId }));
|
|
766
|
+
if (outcome._tag !== "Activated")
|
|
767
|
+
return yield* Effect.fail(new ClaimLost({ runId }));
|
|
768
|
+
const summary = yield* summaryOf(row);
|
|
769
|
+
return yield* transition(runId, claimant, {
|
|
770
|
+
...summary,
|
|
771
|
+
...adopted,
|
|
772
|
+
ownerId: JSON.stringify(claimant)
|
|
773
|
+
}, "accepted");
|
|
774
|
+
});
|
|
775
|
+
/**
|
|
776
|
+
* The run's summary with the code identity it started on.
|
|
777
|
+
*
|
|
778
|
+
* A row with no control summary of its own (a trampoline round, or a fork
|
|
779
|
+
* or child the engine wrote) records no identity, and checking it against
|
|
780
|
+
* nothing let it resume on any code. It inherits the identity of its
|
|
781
|
+
* nearest ancestor of the same flow that recorded one.
|
|
782
|
+
*/
|
|
783
|
+
const recordedCode = (row) => Effect.gen(function* () {
|
|
784
|
+
const summary = yield* summaryOf(row);
|
|
785
|
+
const seen = new Set([row.runId]);
|
|
786
|
+
let parentId = optional(row.parentRunId).value ?? summary.parentRunId;
|
|
787
|
+
while (summary.executionDigest === undefined && summary.engineVersion === undefined &&
|
|
788
|
+
parentId !== undefined && !seen.has(parentId)) {
|
|
789
|
+
seen.add(parentId);
|
|
790
|
+
const parentRow = yield* requireRow(parentId).pipe(Effect.catchTag("/control/RunNotFound", () => Effect.succeed(undefined)));
|
|
791
|
+
if (parentRow === undefined)
|
|
792
|
+
break;
|
|
793
|
+
const parent = yield* summaryOf(parentRow);
|
|
794
|
+
if (parent.flowId !== summary.flowId)
|
|
795
|
+
break;
|
|
796
|
+
if (parent.executionDigest !== undefined || parent.engineVersion !== undefined) {
|
|
797
|
+
return { ...summary, executionDigest: parent.executionDigest, engineVersion: parent.engineVersion };
|
|
798
|
+
}
|
|
799
|
+
parentId = optional(parentRow.parentRunId).value ?? parent.parentRunId;
|
|
800
|
+
}
|
|
801
|
+
return summary;
|
|
802
|
+
});
|
|
803
|
+
/**
|
|
804
|
+
* The code identity an allowed drift records, read before the claim: a
|
|
805
|
+
* flow this host cannot run leaves the run where it was rather than
|
|
806
|
+
* accepted and then failed (#2740).
|
|
807
|
+
*/
|
|
808
|
+
const adoptable = (row) => Effect.gen(function* () {
|
|
809
|
+
const summary = yield* summaryOf(row);
|
|
810
|
+
const flow = yield* readAdoptedFlow(summary.flowId, summary.runId);
|
|
811
|
+
const gone = flow === undefined
|
|
812
|
+
? codeDriftOf(yield* recordedCode(row), undefined, options.engineVersion)
|
|
813
|
+
: undefined;
|
|
814
|
+
if (gone !== undefined)
|
|
815
|
+
return yield* gone;
|
|
816
|
+
return adoptedCode(summary, flow, options.engineVersion);
|
|
817
|
+
});
|
|
818
|
+
/** `resume`, recording the identity `adopt` answers on the claimed run when given. */
|
|
819
|
+
const resumeRun = (runId, scope, adopt) => Effect.gen(function* () {
|
|
820
|
+
const row = yield* requireRow(runId);
|
|
821
|
+
const summary = yield* summaryOf(row);
|
|
822
|
+
if (terminal(summary.status))
|
|
823
|
+
return summary;
|
|
824
|
+
// Start-or-join: owning the run already means resume is a no-op.
|
|
825
|
+
if (ownedByUs(row))
|
|
826
|
+
return summary;
|
|
827
|
+
// Parking clears ownership, but the detached host can still be alive.
|
|
828
|
+
// Public resume may take its park only after a same-host dead-pid
|
|
829
|
+
// probe; otherwise the caller would steal and interrupt its execution.
|
|
830
|
+
// The refusal names that host, so `Control.resume` hands the resume
|
|
831
|
+
// to it instead (#3342). Trusted host claims remain able to reclaim
|
|
832
|
+
// their engine's execution.
|
|
833
|
+
if (scope === "launched" && row.status === "suspended" && summary.parkedBy !== undefined) {
|
|
834
|
+
const parkedOwner = yield* decodeStoredJson("parked host", Ownership.OwnerId, summary.parkedBy);
|
|
835
|
+
if (!sameProcess(parkedOwner, owner)) {
|
|
836
|
+
const alive = isAlive === undefined || !Ownership.sameHostIncarnation(parkedOwner, owner)
|
|
837
|
+
? true
|
|
838
|
+
: yield* isAlive(parkedOwner, { claimant: owner, heartbeatAtMs: row.heartbeatAtMs, nowMs: yield* now });
|
|
839
|
+
if (alive) {
|
|
840
|
+
return yield* new ClaimLost({
|
|
841
|
+
runId,
|
|
842
|
+
reason: "The host that parked this run is still alive or its liveness is unknown",
|
|
843
|
+
parkedBy: { hostId: parkedOwner.hostId, pid: parkedOwner.pid }
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// Every public Control resume and steer wake uses launched scope.
|
|
849
|
+
// Engine-created runs keep their continuation and driver. Unrestricted
|
|
850
|
+
// claims are a trusted low-level capability for hosts that can drive
|
|
851
|
+
// the execution; node approval delegates through requestResume instead.
|
|
852
|
+
if (scope === "launched") {
|
|
853
|
+
const indexed = yield* sql `SELECT run_id FROM control_runs WHERE run_id = ${runId}`.pipe(Effect.mapError(persistence("read the launch index")));
|
|
854
|
+
if (indexed.length === 0)
|
|
855
|
+
return yield* new ClaimLost({ runId });
|
|
856
|
+
}
|
|
857
|
+
// A run owned by a live peer is theirs to drive. A run whose owner is
|
|
858
|
+
// gone is taken over, with the evidence the run store checks.
|
|
859
|
+
if (row.status === "running") {
|
|
860
|
+
const evidence = yield* deadOwner(row);
|
|
861
|
+
return evidence === undefined
|
|
862
|
+
? yield* new ClaimLost({ runId })
|
|
863
|
+
: yield* claim(runId, row, evidence, adopt === undefined ? undefined : yield* adopt(row));
|
|
864
|
+
}
|
|
865
|
+
return yield* claim(runId, row, undefined, adopt === undefined ? undefined : yield* adopt(row));
|
|
866
|
+
});
|
|
867
|
+
// Match summaryFrom's durable fields in SQL. Only the selected ids are
|
|
868
|
+
// decoded or expanded into ancestry; one extra key determines continuation.
|
|
869
|
+
const runPageKeys = (request, includeSpawn = true, includeWaitRollup = true) => {
|
|
870
|
+
const filters = request.filters;
|
|
871
|
+
const source = sql `CASE WHEN indexed.created_seq IS NULL THEN 1 ELSE 0 END`;
|
|
872
|
+
const sequence = sql `COALESCE(indexed.created_seq, 0)`;
|
|
873
|
+
const controlState = sql `(${Dialect.jsonText(sql, sql `runs.state_json`, "$.runId")} IS NOT NULL
|
|
874
|
+
OR ${Dialect.jsonText(sql, sql `runs.state_json`, "$.flowId")} IS NOT NULL
|
|
875
|
+
OR ${Dialect.jsonText(sql, sql `runs.state_json`, "$.status")} IS NOT NULL)`;
|
|
876
|
+
const flowId = sql `CASE WHEN ${controlState} THEN ${Dialect.jsonText(sql, sql `runs.state_json`, "$.flowId")}
|
|
877
|
+
ELSE ${Dialect.jsonText(sql, sql `runs.state_json`, "$.flowName")} END`;
|
|
878
|
+
const ownStatus = sql `CASE WHEN ${controlState} THEN ${Dialect.jsonText(sql, sql `runs.state_json`, "$.status")}
|
|
879
|
+
ELSE CASE runs.status WHEN 'pending' THEN 'accepted' WHEN 'suspended' THEN 'parked' ELSE runs.status END END`;
|
|
880
|
+
// The listing filter has to agree with the summary the listing returns.
|
|
881
|
+
// `summaryFrom` rolls a run tree's open human waits up onto the root's
|
|
882
|
+
// status, so the SQL that decides which runs a `status:
|
|
883
|
+
// "waiting-approval"` page contains must roll them up too — otherwise
|
|
884
|
+
// the inbox filter skips exactly the rows the inbox is for, which is how
|
|
885
|
+
// run-3 stayed invisible while parked on `coding-clarification`.
|
|
886
|
+
const status = includeWaitRollup
|
|
887
|
+
? sql `CASE
|
|
888
|
+
WHEN runs.status NOT IN ('completed', 'failed', 'cancelled')
|
|
889
|
+
AND runs.run_id IN (SELECT ancestorId FROM human_wait_ancestry)
|
|
890
|
+
THEN 'waiting-approval' ELSE ${ownStatus} END`
|
|
891
|
+
: ownStatus;
|
|
892
|
+
const storedParent = sql `CASE WHEN ${controlState} THEN ${Dialect.jsonText(sql, sql `runs.state_json`, "$.parentRunId")} END`;
|
|
893
|
+
const storedLineage = sql `CASE WHEN ${controlState} THEN ${Dialect.jsonText(sql, sql `runs.state_json`, "$.lineageId")} END`;
|
|
894
|
+
const parent = includeSpawn
|
|
895
|
+
? sql `COALESCE(runs.parent_run_id,
|
|
896
|
+
(SELECT parent_id FROM flows_run_parents WHERE child_id = runs.run_id ORDER BY seq LIMIT 1),
|
|
897
|
+
${storedParent})`
|
|
898
|
+
: sql `COALESCE(runs.parent_run_id, ${storedParent})`;
|
|
899
|
+
const lineage = sql `COALESCE(runs.lineage_id, ${storedLineage})`;
|
|
900
|
+
const after = request.cursor;
|
|
901
|
+
const conditions = [sql `1 = 1`];
|
|
902
|
+
if (filters?.flowId !== undefined)
|
|
903
|
+
conditions.push(sql `${flowId} = ${filters.flowId}`);
|
|
904
|
+
if (filters?.status !== undefined)
|
|
905
|
+
conditions.push(sql `${status} = ${filters.status}`);
|
|
906
|
+
if (filters?.terminal !== undefined) {
|
|
907
|
+
const isTerminal = sql `${status} IN ('completed', 'failed', 'cancelled')`;
|
|
908
|
+
conditions.push(filters.terminal ? isTerminal : sql `NOT (${isTerminal})`);
|
|
909
|
+
}
|
|
910
|
+
if (filters?.parentRunId !== undefined)
|
|
911
|
+
conditions.push(sql `${parent} = ${filters.parentRunId}`);
|
|
912
|
+
if (filters?.lineageId !== undefined)
|
|
913
|
+
conditions.push(sql `${lineage} = ${filters.lineageId}`);
|
|
914
|
+
if (filters?.since !== undefined)
|
|
915
|
+
conditions.push(sql `runs.created_at_ms >= ${filters.since}`);
|
|
916
|
+
if (filters?.until !== undefined)
|
|
917
|
+
conditions.push(sql `runs.created_at_ms < ${filters.until}`);
|
|
918
|
+
if (filters?.launchedBy !== undefined) {
|
|
919
|
+
conditions.push(sql `indexed.principal_id = ${filters.launchedBy.id}`);
|
|
920
|
+
if (filters.launchedBy.kind !== undefined) {
|
|
921
|
+
conditions.push(sql `indexed.principal_kind = ${filters.launchedBy.kind}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (filters?.runIds !== undefined) {
|
|
925
|
+
// One JSON parameter, so a trigger's whole ledger never meets the bind-variable limit.
|
|
926
|
+
const ids = JSON.stringify([...new Set(filters.runIds)]);
|
|
927
|
+
conditions.push(sql.onDialectOrElse({
|
|
928
|
+
/* v8 ignore next -- PostgreSQL adapter; covered by //packages/smithers/control:postgresInventory */
|
|
929
|
+
pg: () => sql `runs.run_id IN (SELECT jsonb_array_elements_text(${ids}::jsonb))`,
|
|
930
|
+
orElse: () => sql `runs.run_id IN (SELECT value FROM json_each(${ids}))`
|
|
931
|
+
}));
|
|
932
|
+
}
|
|
933
|
+
if (after !== undefined) {
|
|
934
|
+
conditions.push(request.order === "newest"
|
|
935
|
+
? sql `(runs.created_at_ms, ${source}, ${sequence}, runs.run_id) <
|
|
936
|
+
(${after.createdAt}, ${after.source}, ${after.sequence}, ${after.runId})`
|
|
937
|
+
: request.order === "oldest"
|
|
938
|
+
? sql `(runs.created_at_ms, ${source}, ${sequence}, runs.run_id) >
|
|
939
|
+
(${after.createdAt}, ${after.source}, ${after.sequence}, ${after.runId})`
|
|
940
|
+
: sql `(${source}, ${sequence}, runs.created_at_ms, runs.run_id) >
|
|
941
|
+
(${after.source}, ${after.sequence}, ${after.createdAt}, ${after.runId})`);
|
|
942
|
+
}
|
|
943
|
+
// Computed once for the whole page rather than per row: the set of runs
|
|
944
|
+
// with an open human wait at or below them, climbed from the few parked
|
|
945
|
+
// rows instead of descended from every run.
|
|
946
|
+
const waitAncestry = includeWaitRollup
|
|
947
|
+
? sql `WITH RECURSIVE human_wait_ancestry(ancestorId, depth) AS (
|
|
948
|
+
SELECT run_id, 0 FROM flows_runs
|
|
949
|
+
WHERE waiting_reason = ${ControlExecutor.humanWaitReason}
|
|
950
|
+
AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
951
|
+
UNION
|
|
952
|
+
SELECT parent.run_id, human_wait_ancestry.depth + 1
|
|
953
|
+
FROM flows_runs step JOIN human_wait_ancestry ON step.run_id = human_wait_ancestry.ancestorId
|
|
954
|
+
JOIN flows_runs parent ON parent.run_id IN (
|
|
955
|
+
SELECT parent_id FROM flows_run_parents WHERE child_id = step.run_id
|
|
956
|
+
UNION ALL
|
|
957
|
+
SELECT step.parent_run_id WHERE step.parent_run_id IS NOT NULL
|
|
958
|
+
)
|
|
959
|
+
WHERE human_wait_ancestry.depth < ${maxWaitTreeDepth}
|
|
960
|
+
AND (parent.run_id = step.parent_run_id
|
|
961
|
+
OR COALESCE(${Dialect.jsonText(sql, sql `step.state_json`, "$.onParentExit")}, 'cancel') <> 'detach')
|
|
962
|
+
)`
|
|
963
|
+
: sql.literal("");
|
|
964
|
+
return sql `
|
|
965
|
+
${waitAncestry}
|
|
966
|
+
SELECT runs.run_id AS "runId", ${source} AS source, ${sequence} AS sequence,
|
|
967
|
+
runs.created_at_ms AS "createdAt"
|
|
968
|
+
FROM flows_runs AS runs LEFT JOIN control_runs AS indexed ON indexed.run_id = runs.run_id
|
|
969
|
+
WHERE ${sql.and(conditions)}
|
|
970
|
+
ORDER BY ${request.order === "newest"
|
|
971
|
+
? sql `runs.created_at_ms DESC, ${source} DESC, ${sequence} DESC, runs.run_id DESC`
|
|
972
|
+
: request.order === "oldest"
|
|
973
|
+
? sql `runs.created_at_ms, ${source}, ${sequence}, runs.run_id`
|
|
974
|
+
: sql `${source}, ${sequence}, runs.created_at_ms, runs.run_id`}
|
|
975
|
+
LIMIT ${request.limit + 1}
|
|
976
|
+
`.pipe(probe, Effect.catchIf((error) => includeSpawn && filters?.parentRunId !== undefined && missingTable("flows_run_parents")(error), () => runPageKeys(request, false, includeWaitRollup)),
|
|
977
|
+
// Without the engine's wait-tree schema, retain the ordinary page.
|
|
978
|
+
Effect.catchIf((error) => includeWaitRollup && missingWaitTreeSchema(error), () => runPageKeys(request, includeSpawn, false)), Effect.mapError(persistence("query runs")));
|
|
979
|
+
};
|
|
980
|
+
/**
|
|
981
|
+
* One inventory page of `table` by `rowid`: the insertion-ordered row key
|
|
982
|
+
* both dialects index (SQLite's b-tree key, PostgreSQL's identity column),
|
|
983
|
+
* so a page is one seek and `limit + 1` rows however large the table is.
|
|
984
|
+
* The first page pins `through` to the newest row, so a walk ends while
|
|
985
|
+
* inserts continue.
|
|
986
|
+
*/
|
|
987
|
+
const pageByRowId = (request, table, column, operation) => Effect.gen(function* () {
|
|
988
|
+
yield* idPageLimit(request.limit);
|
|
989
|
+
const through = request.through ?? Number((yield* sql `
|
|
990
|
+
SELECT MAX(rowid) AS newest FROM ${sql.literal(table)}
|
|
991
|
+
`.pipe(query(operation)))[0]?.newest ?? 0);
|
|
992
|
+
const rows = yield* sql `
|
|
993
|
+
SELECT ${sql.literal(column)} AS id, rowid AS position FROM ${sql.literal(table)}
|
|
994
|
+
WHERE rowid > ${request.after ?? 0} AND rowid <= ${through}
|
|
995
|
+
ORDER BY rowid LIMIT ${request.limit + 1}
|
|
996
|
+
`.pipe(query(operation));
|
|
997
|
+
const selected = rows.slice(0, request.limit);
|
|
998
|
+
return rows.length > request.limit
|
|
999
|
+
? { ids: selected.map((row) => row.id), next: Number(selected.at(-1).position), through }
|
|
1000
|
+
: { ids: selected.map((row) => row.id), through };
|
|
1001
|
+
});
|
|
1002
|
+
const pagePlanIds = (request) => pageByRowId(request, "control_plans", "plan_id", "page plans");
|
|
1003
|
+
/**
|
|
1004
|
+
* One page of stored plans by `rowid`. The decision narrows in SQL; the flow
|
|
1005
|
+
* is read from the stored card, so a page reads batches until it fills or
|
|
1006
|
+
* the table ends.
|
|
1007
|
+
*/
|
|
1008
|
+
const queryPlans = (request) => Effect.gen(function* () {
|
|
1009
|
+
yield* idPageLimit(request.limit);
|
|
1010
|
+
const plans = [];
|
|
1011
|
+
let after = request.after ?? 0;
|
|
1012
|
+
while (true) {
|
|
1013
|
+
const conditions = [
|
|
1014
|
+
sql `rowid > ${after}`,
|
|
1015
|
+
...(request.decision === undefined ? [] : [sql `decision = ${request.decision}`])
|
|
1016
|
+
];
|
|
1017
|
+
const rows = yield* sql `
|
|
1018
|
+
SELECT plan_id AS "planId", card_json AS "cardJson",
|
|
1019
|
+
decoded_input_json AS "decodedInputJson", decision, rowid AS position
|
|
1020
|
+
FROM control_plans WHERE ${sql.and(conditions)}
|
|
1021
|
+
ORDER BY rowid LIMIT ${planScanBatch}
|
|
1022
|
+
`.pipe(query("page plans"));
|
|
1023
|
+
for (const [index, row] of rows.entries()) {
|
|
1024
|
+
after = Number(row.position);
|
|
1025
|
+
const plan = yield* storedPlan(row);
|
|
1026
|
+
if (request.flowId !== undefined && plan.card.flowId !== request.flowId)
|
|
1027
|
+
continue;
|
|
1028
|
+
plans.push(plan);
|
|
1029
|
+
if (plans.length === request.limit) {
|
|
1030
|
+
return index < rows.length - 1 || rows.length === planScanBatch ? { plans, next: after } : { plans };
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (rows.length < planScanBatch)
|
|
1034
|
+
return { plans };
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
/**
|
|
1038
|
+
* The recorded launcher of a run this plane launched. The launch index is
|
|
1039
|
+
* written once, so it outlives the control summary the engine replaces in
|
|
1040
|
+
* `flows_runs.state_json` when it takes the run over.
|
|
1041
|
+
*/
|
|
1042
|
+
const launcherOf = (runId) => Effect.map(sql `
|
|
1043
|
+
SELECT principal_id AS id, principal_kind AS kind FROM control_runs WHERE run_id = ${runId}
|
|
1044
|
+
`.pipe(query("read a run launcher")), (rows) => {
|
|
1045
|
+
const row = rows[0];
|
|
1046
|
+
return row === undefined || row.id === null || row.kind === null ? undefined : { id: row.id, kind: row.kind };
|
|
1047
|
+
});
|
|
1048
|
+
const messages = (runId, kind, schema) => sql `
|
|
1049
|
+
SELECT payload_json AS "payloadJson" FROM control_run_messages
|
|
1050
|
+
WHERE run_id = ${runId} AND kind = ${kind} ORDER BY seq
|
|
1051
|
+
`.pipe(query("read run messages"), Effect.flatMap((rows) => Effect.forEach(rows, (row) => decodeStoredJson(`control_run_messages.payload_json as ${kind}`, schema, row.payloadJson))));
|
|
1052
|
+
const appendMessage = (runId, kind, payload) => Effect.gen(function* () {
|
|
1053
|
+
yield* requireRow(runId);
|
|
1054
|
+
yield* sql `
|
|
1055
|
+
INSERT INTO control_run_messages (run_id, kind, payload_json)
|
|
1056
|
+
VALUES (${runId}, ${kind}, ${JSON.stringify(payload)})
|
|
1057
|
+
`.pipe(Effect.mapError(persistence("append a run message")));
|
|
1058
|
+
});
|
|
1059
|
+
const service = make({
|
|
1060
|
+
authorizeApproval,
|
|
1061
|
+
plan: Effect.fn("SqlControlRuntime.plan")(function* (input) {
|
|
1062
|
+
const flow = (yield* readFlows).get(input.flowId);
|
|
1063
|
+
if (flow === undefined) {
|
|
1064
|
+
return yield* new FlowNotFound({ flowId: input.flowId });
|
|
1065
|
+
}
|
|
1066
|
+
const requestFingerprint = yield* Effect.try({
|
|
1067
|
+
try: () => planFingerprint(input),
|
|
1068
|
+
catch: (cause) => new InvalidInput({ issue: canonicalIssue(cause) })
|
|
1069
|
+
});
|
|
1070
|
+
if (input.idempotencyKey !== undefined) {
|
|
1071
|
+
const prior = yield* sql `
|
|
1072
|
+
SELECT fingerprint, plan_id AS "planId" FROM control_plan_keys
|
|
1073
|
+
WHERE idempotency_key = ${input.idempotencyKey}
|
|
1074
|
+
`.pipe(query("read a plan key"));
|
|
1075
|
+
const found = prior[0];
|
|
1076
|
+
if (found !== undefined) {
|
|
1077
|
+
if (found.fingerprint !== requestFingerprint) {
|
|
1078
|
+
return yield* new InvalidInput({
|
|
1079
|
+
issue: `idempotency key ${input.idempotencyKey} was used for another plan`
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
const stored = yield* readPlan(found.planId);
|
|
1083
|
+
if (Option.isSome(stored)) {
|
|
1084
|
+
const decoded = yield* storedPlan(stored.value);
|
|
1085
|
+
return { card: decoded.card, created: false };
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const decoded = yield* (flow.decode?.(input.input) ?? Effect.try({
|
|
1090
|
+
try: () => {
|
|
1091
|
+
canonical(input.input);
|
|
1092
|
+
return input.input;
|
|
1093
|
+
},
|
|
1094
|
+
catch: (cause) => new InvalidInput({ issue: canonicalIssue(cause) })
|
|
1095
|
+
}));
|
|
1096
|
+
const planId = `plan-${yield* nextSequence("plan")}`;
|
|
1097
|
+
const handoff = flow.plan === undefined ? undefined : yield* flow.plan(decoded, planId);
|
|
1098
|
+
const card = yield* planCard({
|
|
1099
|
+
planId,
|
|
1100
|
+
flowId: input.flowId,
|
|
1101
|
+
decodedInput: decoded,
|
|
1102
|
+
envelope: budgeted(flow.envelope, input.budget),
|
|
1103
|
+
deployClass: flow.deployClass,
|
|
1104
|
+
executionDigest: flow.executionDigest,
|
|
1105
|
+
handoff,
|
|
1106
|
+
idempotencyKey: input.idempotencyKey
|
|
1107
|
+
}).pipe(Effect.provideService(Crypto.Crypto, crypto));
|
|
1108
|
+
const identity = approvalIdentity(card.approval.target);
|
|
1109
|
+
// The key row is claimed FIRST, and the claim is a conditional insert
|
|
1110
|
+
// followed by a read of whoever holds it. `idempotency_key` is the
|
|
1111
|
+
// primary key, so a bare insert made two runtimes planning under one
|
|
1112
|
+
// key a race the loser lost with a constraint violation surfaced as
|
|
1113
|
+
// `PersistenceError`, instead of the winner's card the key promises.
|
|
1114
|
+
// Under Control.plan this write joins the journal transaction, so the
|
|
1115
|
+
// card, key, token and creation entry commit or roll back together.
|
|
1116
|
+
const outcome = yield* writer.write(Effect.gen(function* () {
|
|
1117
|
+
if (input.idempotencyKey !== undefined) {
|
|
1118
|
+
yield* sql `
|
|
1119
|
+
INSERT INTO control_plan_keys (idempotency_key, fingerprint, plan_id)
|
|
1120
|
+
VALUES (${input.idempotencyKey}, ${requestFingerprint}, ${planId})
|
|
1121
|
+
ON CONFLICT (idempotency_key) DO NOTHING
|
|
1122
|
+
`;
|
|
1123
|
+
const settled = yield* sql `
|
|
1124
|
+
SELECT fingerprint, plan_id AS "planId" FROM control_plan_keys
|
|
1125
|
+
WHERE idempotency_key = ${input.idempotencyKey}
|
|
1126
|
+
`;
|
|
1127
|
+
const holder = settled[0];
|
|
1128
|
+
if (holder !== undefined && holder.planId !== planId) {
|
|
1129
|
+
return { _tag: "raced", holder };
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// An explicit null input is stored as JSON text in the non-null column.
|
|
1133
|
+
const decodedJson = JSON.stringify(decoded ?? null);
|
|
1134
|
+
yield* sql `
|
|
1135
|
+
INSERT INTO control_plans (plan_id, card_json, decoded_input_json, decision)
|
|
1136
|
+
VALUES (${planId}, ${JSON.stringify(card)}, ${decodedJson}, 'pending')
|
|
1137
|
+
`;
|
|
1138
|
+
yield* sql `
|
|
1139
|
+
INSERT INTO control_tokens (
|
|
1140
|
+
target_tag, run_id, target_id, token_id, target_json, resolved, decision_principal_json
|
|
1141
|
+
)
|
|
1142
|
+
VALUES (
|
|
1143
|
+
${identity.targetTag}, ${identity.runId}, ${identity.targetId}, ${planId},
|
|
1144
|
+
${JSON.stringify(card.approval.target)}, 0, NULL
|
|
1145
|
+
)
|
|
1146
|
+
`;
|
|
1147
|
+
return { _tag: "stored" };
|
|
1148
|
+
})).pipe(Effect.mapError(persistence("store a plan")));
|
|
1149
|
+
if (outcome._tag === "raced") {
|
|
1150
|
+
if (outcome.holder.fingerprint !== requestFingerprint) {
|
|
1151
|
+
return yield* new InvalidInput({
|
|
1152
|
+
issue: `idempotency key ${String(input.idempotencyKey)} was used for another plan`
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
const stored = yield* readPlan(outcome.holder.planId);
|
|
1156
|
+
if (Option.isNone(stored)) {
|
|
1157
|
+
return yield* new PersistenceError({
|
|
1158
|
+
operation: "read a plan",
|
|
1159
|
+
message: `plan key ${String(input.idempotencyKey)} names plan ${outcome.holder.planId}, which is absent`
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
const decodedHolder = yield* storedPlan(stored.value);
|
|
1163
|
+
return { card: decodedHolder.card, created: false };
|
|
1164
|
+
}
|
|
1165
|
+
return { card, created: true };
|
|
1166
|
+
}),
|
|
1167
|
+
getPlan: Effect.fn("SqlControlRuntime.getPlan")((planId) => Effect.flatMap(requirePlan(planId), storedPlan)),
|
|
1168
|
+
pagePlanIds,
|
|
1169
|
+
queryPlans,
|
|
1170
|
+
lookupApproval: Effect.fn("SqlControlRuntime.lookupApproval")(function* (target) {
|
|
1171
|
+
const tokenId = target._tag === "Plan" ? target.planId : target.requestId;
|
|
1172
|
+
const identity = approvalIdentity(target);
|
|
1173
|
+
const rows = yield* sql `
|
|
1174
|
+
SELECT token_id AS "tokenId", target_json AS "targetJson", resolved,
|
|
1175
|
+
decision_principal_json AS "decisionPrincipalJson", decision_json AS "decisionJson"
|
|
1176
|
+
FROM control_tokens
|
|
1177
|
+
WHERE target_tag = ${identity.targetTag}
|
|
1178
|
+
AND run_id = ${identity.runId}
|
|
1179
|
+
AND target_id = ${identity.targetId}
|
|
1180
|
+
`.pipe(query("read an approval token"));
|
|
1181
|
+
const row = rows[0];
|
|
1182
|
+
if (row === undefined) {
|
|
1183
|
+
return yield* (target._tag === "Node"
|
|
1184
|
+
? new RunNotFound({ runId: target.runId })
|
|
1185
|
+
: new PlanNotFound({ planId: target.planId }));
|
|
1186
|
+
}
|
|
1187
|
+
const stored = yield* decodeStoredJson("control_tokens.target_json", ApprovalTarget, row.targetJson);
|
|
1188
|
+
// The composite columns select the requested identity. The decoded copy
|
|
1189
|
+
// must agree too, or a rewritten JSON blob could smuggle a foreign
|
|
1190
|
+
// target back into the token after lookup.
|
|
1191
|
+
if (!sameApprovalIdentity(stored, target)) {
|
|
1192
|
+
return yield* new PersistenceError({
|
|
1193
|
+
operation: "validate an approval token",
|
|
1194
|
+
message: "The stored approval target does not match its identity"
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
if (stored.digest !== target.digest) {
|
|
1198
|
+
return yield* new PlanDigestMismatch({
|
|
1199
|
+
planId: tokenId,
|
|
1200
|
+
expected: stored.digest,
|
|
1201
|
+
actual: target.digest
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
if (!sameEnvelope(stored.envelope, target.envelope)) {
|
|
1205
|
+
return yield* new EnvelopeMismatch({
|
|
1206
|
+
planId: tokenId,
|
|
1207
|
+
expected: canonical(stored.envelope),
|
|
1208
|
+
actual: canonical(target.envelope)
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
const token = yield* tokenFromRow(row, stored);
|
|
1212
|
+
if (token._tag !== "Pending")
|
|
1213
|
+
return yield* new AlreadyResolved({ requestId: tokenId });
|
|
1214
|
+
return token;
|
|
1215
|
+
}),
|
|
1216
|
+
registerApproval: Effect.fn("SqlControlRuntime.registerApproval")(function* (target) {
|
|
1217
|
+
yield* requireRow(target.runId);
|
|
1218
|
+
const identity = approvalIdentity(target);
|
|
1219
|
+
yield* sql `
|
|
1220
|
+
INSERT INTO control_tokens (
|
|
1221
|
+
target_tag, run_id, target_id, token_id, target_json, resolved, decision_principal_json
|
|
1222
|
+
)
|
|
1223
|
+
VALUES (
|
|
1224
|
+
${identity.targetTag}, ${identity.runId}, ${identity.targetId}, ${target.requestId},
|
|
1225
|
+
${JSON.stringify(target)}, 0, NULL
|
|
1226
|
+
)
|
|
1227
|
+
ON CONFLICT (target_tag, run_id, target_id) DO NOTHING
|
|
1228
|
+
`.pipe(Effect.mapError(persistence("register an approval token")));
|
|
1229
|
+
const rows = yield* sql `
|
|
1230
|
+
SELECT token_id AS "tokenId", target_json AS "targetJson", resolved,
|
|
1231
|
+
decision_principal_json AS "decisionPrincipalJson", decision_json AS "decisionJson"
|
|
1232
|
+
FROM control_tokens
|
|
1233
|
+
WHERE target_tag = ${identity.targetTag}
|
|
1234
|
+
AND run_id = ${identity.runId}
|
|
1235
|
+
AND target_id = ${identity.targetId}
|
|
1236
|
+
`.pipe(query("read an approval token"));
|
|
1237
|
+
const row = rows[0];
|
|
1238
|
+
if (row === undefined) {
|
|
1239
|
+
return yield* Effect.fail(new PersistenceError({
|
|
1240
|
+
operation: "register an approval token",
|
|
1241
|
+
message: "A registered approval token could not be read back"
|
|
1242
|
+
}));
|
|
1243
|
+
}
|
|
1244
|
+
const stored = yield* decodeStoredJson("control_tokens.target_json", ApprovalTarget, row.targetJson);
|
|
1245
|
+
if (!sameApprovalIdentity(stored, target)) {
|
|
1246
|
+
return yield* new PersistenceError({
|
|
1247
|
+
operation: "validate an approval token",
|
|
1248
|
+
message: "The stored approval target does not match its identity"
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
if (stored.digest !== target.digest) {
|
|
1252
|
+
return yield* new PlanDigestMismatch({
|
|
1253
|
+
planId: target.requestId,
|
|
1254
|
+
expected: stored.digest,
|
|
1255
|
+
actual: target.digest
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
if (!sameEnvelope(stored.envelope, target.envelope)) {
|
|
1259
|
+
return yield* new EnvelopeMismatch({
|
|
1260
|
+
planId: target.requestId,
|
|
1261
|
+
expected: canonical(stored.envelope),
|
|
1262
|
+
actual: canonical(target.envelope)
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
return yield* tokenFromRow(row, stored);
|
|
1266
|
+
}),
|
|
1267
|
+
installBulkGrant: Effect.fn("SqlControlRuntime.installBulkGrant")(function* (token, envelope, scope) {
|
|
1268
|
+
const timestamp = yield* now;
|
|
1269
|
+
const identity = approvalIdentity(token.target);
|
|
1270
|
+
// The envelope is installed whole. Splitting it into capabilities here
|
|
1271
|
+
// would let a partial grant exist, which is exactly what the bulk-grant
|
|
1272
|
+
// rule forbids.
|
|
1273
|
+
yield* sql `
|
|
1274
|
+
INSERT INTO control_grants (
|
|
1275
|
+
target_tag, run_id, target_id, token_id, envelope_json, scope, installed_at_ms
|
|
1276
|
+
)
|
|
1277
|
+
VALUES (
|
|
1278
|
+
${identity.targetTag}, ${identity.runId}, ${identity.targetId}, ${token.tokenId},
|
|
1279
|
+
${JSON.stringify(envelope)}, ${scope}, ${timestamp}
|
|
1280
|
+
)
|
|
1281
|
+
ON CONFLICT (target_tag, run_id, target_id) DO NOTHING
|
|
1282
|
+
`.pipe(Effect.mapError(persistence("install a grant")));
|
|
1283
|
+
}),
|
|
1284
|
+
resolveApproval: Effect.fn("SqlControlRuntime.resolveApproval")(function* (token, decision, principal, scope = "once") {
|
|
1285
|
+
// Caller-owned objects can change while the clock or writer yields.
|
|
1286
|
+
// Bind both representations of the principal, and the plan update,
|
|
1287
|
+
// to the same captured request rather than reading the caller again.
|
|
1288
|
+
const requested = yield* Effect.try({
|
|
1289
|
+
try: () => structuredClone({ target: token.target, principal, tokenId: token.tokenId }),
|
|
1290
|
+
catch: () => new PersistenceError({
|
|
1291
|
+
operation: "record an approval decision",
|
|
1292
|
+
message: "The approval request cannot be captured"
|
|
1293
|
+
})
|
|
1294
|
+
});
|
|
1295
|
+
const identity = approvalIdentity(requested.target);
|
|
1296
|
+
const requestedPrincipal = requested.principal;
|
|
1297
|
+
const tokenId = requested.tokenId;
|
|
1298
|
+
const decidedAt = yield* now;
|
|
1299
|
+
const answer = yield* decodeStoredValue("approval decision", ApprovalDecision, decision === "approved"
|
|
1300
|
+
? { _tag: "Approved", decisionPrincipal: requestedPrincipal, decidedAt, scope }
|
|
1301
|
+
: { _tag: "Denied", decisionPrincipal: requestedPrincipal, decidedAt });
|
|
1302
|
+
// Exactly once: the guard is in the UPDATE, so two concurrent decisions
|
|
1303
|
+
// cannot both observe an unresolved token.
|
|
1304
|
+
const resolved = yield* writer.write(Effect.gen(function* () {
|
|
1305
|
+
yield* authorizeApproval({ principal: requestedPrincipal, target: requested.target, decision, scope });
|
|
1306
|
+
const rows = yield* sql `
|
|
1307
|
+
UPDATE control_tokens
|
|
1308
|
+
SET resolved = 1, decision_principal_json = ${JSON.stringify(requestedPrincipal)},
|
|
1309
|
+
decision_json = ${JSON.stringify(answer)}
|
|
1310
|
+
WHERE target_tag = ${identity.targetTag}
|
|
1311
|
+
AND run_id = ${identity.runId}
|
|
1312
|
+
AND target_id = ${identity.targetId}
|
|
1313
|
+
AND resolved = 0
|
|
1314
|
+
RETURNING token_id AS "tokenId"
|
|
1315
|
+
`;
|
|
1316
|
+
if (rows.length === 0)
|
|
1317
|
+
return false;
|
|
1318
|
+
if (identity.targetTag === "Plan") {
|
|
1319
|
+
yield* sql `UPDATE control_plans SET decision = ${decision} WHERE plan_id = ${identity.targetId}`;
|
|
1320
|
+
}
|
|
1321
|
+
return true;
|
|
1322
|
+
})).pipe(Effect.mapError((error) => error instanceof Unauthorized || error instanceof PersistenceError
|
|
1323
|
+
? error
|
|
1324
|
+
: persistence("resolve an approval")(error)));
|
|
1325
|
+
if (!resolved)
|
|
1326
|
+
return yield* new AlreadyResolved({ requestId: tokenId });
|
|
1327
|
+
}),
|
|
1328
|
+
launch: Effect.fn("SqlControlRuntime.launch")(function* (planId, requestedDigest, envelope, principal, reservedRunId) {
|
|
1329
|
+
const row = yield* requirePlan(planId);
|
|
1330
|
+
const plan = yield* storedPlan(row);
|
|
1331
|
+
if (plan.card.digest !== requestedDigest) {
|
|
1332
|
+
return yield* new PlanDigestMismatch({
|
|
1333
|
+
planId,
|
|
1334
|
+
expected: plan.card.digest,
|
|
1335
|
+
actual: requestedDigest
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
if (!sameEnvelope(plan.card.envelope, envelope)) {
|
|
1339
|
+
return yield* new EnvelopeMismatch({
|
|
1340
|
+
planId,
|
|
1341
|
+
expected: canonical(plan.card.envelope),
|
|
1342
|
+
actual: canonical(envelope)
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
if (plan.decision === "pending") {
|
|
1346
|
+
const parked = {
|
|
1347
|
+
_tag: "Parked",
|
|
1348
|
+
receipt: {
|
|
1349
|
+
_tag: "Parked",
|
|
1350
|
+
receiptId: `launch:${planId}`,
|
|
1351
|
+
planId,
|
|
1352
|
+
status: "waiting-approval"
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
return parked;
|
|
1356
|
+
}
|
|
1357
|
+
if (plan.decision !== "approved")
|
|
1358
|
+
return yield* new PlanDenied({ planId });
|
|
1359
|
+
const sequence = yield* nextSequence("run");
|
|
1360
|
+
const runId = reservedRunId ?? `run-${sequence}`;
|
|
1361
|
+
const timestamp = yield* now;
|
|
1362
|
+
const claimant = { ...owner, nonce: randomId() };
|
|
1363
|
+
const summary = {
|
|
1364
|
+
runId,
|
|
1365
|
+
flowId: plan.card.flowId,
|
|
1366
|
+
status: "accepted",
|
|
1367
|
+
planId,
|
|
1368
|
+
planDigest: plan.card.digest,
|
|
1369
|
+
...(plan.card.executionDigest === undefined ? {} : { executionDigest: plan.card.executionDigest }),
|
|
1370
|
+
...(options.engineVersion === undefined ? {} : { engineVersion: options.engineVersion }),
|
|
1371
|
+
ownerId: JSON.stringify(claimant),
|
|
1372
|
+
...(plan.card.envelope.budget.deadline === undefined
|
|
1373
|
+
? {}
|
|
1374
|
+
: { deadlineAt: timestamp + plan.card.envelope.budget.deadline }),
|
|
1375
|
+
createdAt: timestamp,
|
|
1376
|
+
updatedAt: timestamp
|
|
1377
|
+
};
|
|
1378
|
+
yield* runStore.create(runId, JSON.stringify(summary)).pipe(Effect.mapError(persistence("create a run")));
|
|
1379
|
+
yield* sql `INSERT INTO control_runs (run_id, created_seq, principal_id, principal_kind)
|
|
1380
|
+
VALUES (${runId}, ${sequence}, ${principal?.id ?? null}, ${principal?.kind ?? null})`.pipe(Effect.mapError(persistence("index a run")));
|
|
1381
|
+
const outcome = yield* runStore.claimAndOwn(runId, { status: "pending", owner: null, heartbeatAtMs: null }, claimant, timestamp).pipe(Effect.mapError(persistence("claim a new run")));
|
|
1382
|
+
if (outcome._tag !== "Activated")
|
|
1383
|
+
return yield* new ClaimLost({ runId });
|
|
1384
|
+
const started = {
|
|
1385
|
+
_tag: "Started",
|
|
1386
|
+
receipt: accepted(`launch:${planId}:${runId}`, runId),
|
|
1387
|
+
run: withLauncher(summary, principal)
|
|
1388
|
+
};
|
|
1389
|
+
return started;
|
|
1390
|
+
}),
|
|
1391
|
+
getRun: Effect.fn("SqlControlRuntime.getRun")((runId) => Effect.gen(function* () {
|
|
1392
|
+
const row = yield* requireRow(runId);
|
|
1393
|
+
return yield* summaryFrom(row, yield* ancestryIndex(yield* ancestorChain(runId)));
|
|
1394
|
+
})),
|
|
1395
|
+
/**
|
|
1396
|
+
* Every durable run in `flows_runs` insertion order: `control_runs`
|
|
1397
|
+
* indexes only the runs this plane launched, while a child, a fork, and a
|
|
1398
|
+
* later trampoline round are created by the engine straight into
|
|
1399
|
+
* `flows_runs`. One indexed seek per page, with no row decoded, so a
|
|
1400
|
+
* caller that walks every run holds one page of ids at a time.
|
|
1401
|
+
*/
|
|
1402
|
+
pageRunIds: (request) => pageByRowId(request, "flows_runs", "run_id", "page runs"),
|
|
1403
|
+
queryRuns: Effect.fn("SqlControlRuntime.queryRuns")(function* (request) {
|
|
1404
|
+
if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 500) {
|
|
1405
|
+
return yield* new InvalidInput({ issue: "limit: must be an integer between 1 and 500" });
|
|
1406
|
+
}
|
|
1407
|
+
const keys = yield* runPageKeys(request);
|
|
1408
|
+
const selected = keys.slice(0, request.limit);
|
|
1409
|
+
if (selected.length === 0)
|
|
1410
|
+
return { items: [] };
|
|
1411
|
+
const chains = yield* Effect.forEach(selected, (key) => ancestorChain(key.runId));
|
|
1412
|
+
const ancestry = yield* ancestryIndex([...new Set(chains.flat())]);
|
|
1413
|
+
const summaries = yield* Effect.forEach(selected, (key) => requireRow(key.runId).pipe(Effect.flatMap((row) => Effect.map(summaryFrom(row, ancestry), Option.some)), Effect.catchTag("/control/RunNotFound", () => Effect.succeed(Option.none()))));
|
|
1414
|
+
return {
|
|
1415
|
+
items: summaries.filter(Option.isSome).map((summary) => summary.value),
|
|
1416
|
+
...(keys.length > request.limit ? { nextCursor: selected[selected.length - 1] } : {})
|
|
1417
|
+
};
|
|
1418
|
+
}),
|
|
1419
|
+
listFlows: Effect.fn("SqlControlRuntime.listFlows")(() => Effect.map(readFlows, (flows) => Array.from(flows.values(), (flow) => ({
|
|
1420
|
+
flowId: flow.flowId,
|
|
1421
|
+
description: flow.description
|
|
1422
|
+
}))))(),
|
|
1423
|
+
deliverSignal: Effect.fn("SqlControlRuntime.deliverSignal")((runId, signal) =>
|
|
1424
|
+
// Durable delivery, and deliberately no resumption: a signal records a
|
|
1425
|
+
// fact, it does not decide who runs next.
|
|
1426
|
+
appendMessage(runId, "signal", signal)),
|
|
1427
|
+
admitSignal: Effect.fn("SqlControlRuntime.admitSignal")(function* (commandId, runId, signal, principal) {
|
|
1428
|
+
yield* requireRow(runId);
|
|
1429
|
+
const payloadJson = JSON.stringify(signal);
|
|
1430
|
+
const principalJson = principal === undefined ? null : JSON.stringify(principal);
|
|
1431
|
+
yield* writer.write(sql `INSERT INTO control_signal_commands (command_id, run_id, payload_json, principal_json)
|
|
1432
|
+
VALUES (${commandId}, ${runId}, ${payloadJson}, ${principalJson}) ON CONFLICT(command_id) DO NOTHING`).pipe(Effect.mapError(persistence("admit signal command")));
|
|
1433
|
+
}),
|
|
1434
|
+
signalCommand: Effect.fn("SqlControlRuntime.signalCommand")(function* (commandId) {
|
|
1435
|
+
const rows = yield* sql `
|
|
1436
|
+
SELECT command_id AS "commandId", run_id AS "runId", payload_json AS "payloadJson", wait_token AS token, state,
|
|
1437
|
+
principal_json AS "principalJson"
|
|
1438
|
+
FROM control_signal_commands WHERE command_id = ${commandId}`.pipe(query("read signal command"));
|
|
1439
|
+
const row = rows[0];
|
|
1440
|
+
if (row === undefined)
|
|
1441
|
+
return undefined;
|
|
1442
|
+
return {
|
|
1443
|
+
commandId: row.commandId,
|
|
1444
|
+
runId: row.runId,
|
|
1445
|
+
token: row.token,
|
|
1446
|
+
state: row.state,
|
|
1447
|
+
signal: yield* decodeStoredJson("control_signal_commands.payload_json", SignalPayload, row.payloadJson),
|
|
1448
|
+
...yield* signalPrincipal(row.principalJson)
|
|
1449
|
+
};
|
|
1450
|
+
}),
|
|
1451
|
+
pendingSignals: Effect.gen(function* () {
|
|
1452
|
+
const read = (after) => sql `
|
|
1453
|
+
SELECT seq, command_id AS "commandId", run_id AS "runId", payload_json AS "payloadJson", wait_token AS token, state,
|
|
1454
|
+
principal_json AS "principalJson"
|
|
1455
|
+
FROM control_signal_commands WHERE state = 'pending' AND seq > ${after} ORDER BY seq LIMIT 100`.pipe(query("read pending signals"));
|
|
1456
|
+
let rows = yield* read(pendingSignalCursor);
|
|
1457
|
+
if (rows.length === 0 && pendingSignalCursor !== 0)
|
|
1458
|
+
rows = yield* read(0);
|
|
1459
|
+
pendingSignalCursor = rows.at(-1)?.seq ?? 0;
|
|
1460
|
+
const decoded = yield* Effect.forEach(rows, (row) => Effect.all([
|
|
1461
|
+
decodeStoredJson("control_signal_commands.payload_json", SignalPayload, row.payloadJson),
|
|
1462
|
+
signalPrincipal(row.principalJson)
|
|
1463
|
+
]).pipe(Effect.map(([signal, principal]) => Option.some({
|
|
1464
|
+
commandId: row.commandId,
|
|
1465
|
+
runId: row.runId,
|
|
1466
|
+
token: row.token,
|
|
1467
|
+
state: row.state,
|
|
1468
|
+
signal,
|
|
1469
|
+
...principal
|
|
1470
|
+
})), Effect.catch((error) => writer.write(sql `UPDATE control_signal_commands SET state = 'rejected' WHERE command_id = ${row.commandId} AND state = 'pending'`).pipe(Effect.mapError(persistence("quarantine malformed signal")), Effect.andThen(Effect.logWarning("Malformed admitted signal rejected", { commandId: row.commandId, error })), Effect.as(Option.none())))));
|
|
1471
|
+
return decoded.filter(Option.isSome).map((item) => item.value);
|
|
1472
|
+
}),
|
|
1473
|
+
bindSignal: Effect.fn("SqlControlRuntime.bindSignal")((commandId, token) => writer.write(Effect.gen(function* () {
|
|
1474
|
+
yield* sql `UPDATE control_signal_commands SET wait_token = ${token} WHERE command_id = ${commandId} AND wait_token IS NULL AND state = 'pending' AND NOT EXISTS (SELECT 1 FROM control_signal_commands WHERE wait_token = ${token})`;
|
|
1475
|
+
const rows = yield* sql `SELECT wait_token AS token FROM control_signal_commands WHERE command_id = ${commandId}`;
|
|
1476
|
+
if (rows[0] === undefined) {
|
|
1477
|
+
return yield* new PersistenceError({
|
|
1478
|
+
operation: "bind signal",
|
|
1479
|
+
message: `No pending signal command ${commandId}`
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
return rows[0].token;
|
|
1483
|
+
})).pipe(Effect.mapError(persistence("bind signal")))),
|
|
1484
|
+
settleSignal: Effect.fn("SqlControlRuntime.settleSignal")((commandId, state) => writer.write(sql `UPDATE control_signal_commands SET state = ${state} WHERE command_id = ${commandId} AND state = 'pending'`).pipe(Effect.asVoid, Effect.mapError(persistence("settle signal")))),
|
|
1485
|
+
deliveredSignals: Effect.fn("SqlControlRuntime.deliveredSignals")(function* (runId) {
|
|
1486
|
+
yield* requireRow(runId);
|
|
1487
|
+
const legacy = yield* messages(runId, "signal", SignalPayload);
|
|
1488
|
+
const rows = yield* sql `SELECT payload_json AS "payloadJson" FROM control_signal_commands WHERE run_id = ${runId} AND state != 'rejected' ORDER BY seq`
|
|
1489
|
+
.pipe(query("read admitted signals"));
|
|
1490
|
+
return [
|
|
1491
|
+
...legacy,
|
|
1492
|
+
...yield* Effect.forEach(rows, (row) => decodeStoredJson("control_signal_commands.payload_json", SignalPayload, row.payloadJson))
|
|
1493
|
+
];
|
|
1494
|
+
}),
|
|
1495
|
+
requestResume: Effect.fn("SqlControlRuntime.requestResume")(function* (runId, options) {
|
|
1496
|
+
const summary = yield* summaryOf(yield* requireRow(runId));
|
|
1497
|
+
// A settled run has no host left to take the delegation up: recording
|
|
1498
|
+
// one anyway leaves an orphaned row that `pendingResumes` filters out
|
|
1499
|
+
// of every poll but nothing ever clears.
|
|
1500
|
+
if (terminal(summary.status)) {
|
|
1501
|
+
return yield* new InvalidInput({
|
|
1502
|
+
issue: `run ${runId} is ${summary.status} and cannot take a resume`
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
const sequence = yield* nextSequence("resume");
|
|
1506
|
+
const timestamp = yield* now;
|
|
1507
|
+
const consent = options?.consent ?? null;
|
|
1508
|
+
// A delegation that arrives before the host took up an operator's
|
|
1509
|
+
// resume keeps that consent; only a newer explicit resume replaces it.
|
|
1510
|
+
yield* writer.write(sql `
|
|
1511
|
+
INSERT INTO control_run_resumes (run_id, requested_seq, requested_at_ms, consent_seq)
|
|
1512
|
+
VALUES (${runId}, ${sequence}, ${timestamp}, ${consent})
|
|
1513
|
+
ON CONFLICT (run_id) DO UPDATE SET
|
|
1514
|
+
requested_seq = excluded.requested_seq,
|
|
1515
|
+
requested_at_ms = excluded.requested_at_ms,
|
|
1516
|
+
consent_seq = COALESCE(excluded.consent_seq, control_run_resumes.consent_seq)
|
|
1517
|
+
`).pipe(Effect.mapError(persistence("record a resume delegation")));
|
|
1518
|
+
return sequence;
|
|
1519
|
+
}),
|
|
1520
|
+
// Terminal runs are filtered in SQL: a delegation nobody will ever take
|
|
1521
|
+
// up must not keep appearing in every host's poll.
|
|
1522
|
+
pendingResumes: sql `
|
|
1523
|
+
SELECT resumes.run_id AS "runId",
|
|
1524
|
+
resumes.requested_seq AS "requestedSeq",
|
|
1525
|
+
resumes.requested_at_ms AS "requestedAtMs",
|
|
1526
|
+
resumes.consent_seq AS "consentSeq"
|
|
1527
|
+
FROM control_run_resumes AS resumes
|
|
1528
|
+
JOIN flows_runs AS runs ON runs.run_id = resumes.run_id
|
|
1529
|
+
WHERE runs.status NOT IN ('completed', 'failed', 'cancelled')
|
|
1530
|
+
ORDER BY resumes.requested_seq
|
|
1531
|
+
`.pipe(query("read pending resumes"), Effect.map((rows) => rows.map((row) => ({
|
|
1532
|
+
runId: row.runId,
|
|
1533
|
+
sequence: Number(row.requestedSeq),
|
|
1534
|
+
requestedAtMs: Number(row.requestedAtMs),
|
|
1535
|
+
...(row.consentSeq === null ? {} : { consent: Number(row.consentSeq) })
|
|
1536
|
+
})))),
|
|
1537
|
+
clearResume: Effect.fn("SqlControlRuntime.clearResume")((runId, sequence) => writer.write(sql `
|
|
1538
|
+
DELETE FROM control_run_resumes WHERE run_id = ${runId} AND requested_seq = ${sequence}
|
|
1539
|
+
`).pipe(Effect.mapError(persistence("clear a resume delegation")), Effect.asVoid)),
|
|
1540
|
+
registerFiber: Effect.fn("SqlControlRuntime.registerFiber")(function* (runId, fiber) {
|
|
1541
|
+
yield* requireRow(runId);
|
|
1542
|
+
ActiveFibers.register(fibers, runId, fiber);
|
|
1543
|
+
}),
|
|
1544
|
+
interrupt: Effect.fn("SqlControlRuntime.interrupt")(function* (runId, settle = (effect) => effect) {
|
|
1545
|
+
const row = yield* requireRow(runId);
|
|
1546
|
+
const summary = yield* summaryOf(row);
|
|
1547
|
+
// Terminality is asked FIRST, as `resume` asks it. A settled run has
|
|
1548
|
+
// released its owner, so `ownedByUs` is false for every process
|
|
1549
|
+
// including the one that ran it, and asking ownership first answered
|
|
1550
|
+
// `ClaimLost` — "somebody else has it" — for a run that had simply
|
|
1551
|
+
// finished. Its caller has a `Terminal` receipt for exactly this.
|
|
1552
|
+
if (terminal(summary.status))
|
|
1553
|
+
return summary;
|
|
1554
|
+
if (!ownedByUs(row))
|
|
1555
|
+
return yield* new ClaimLost({ runId });
|
|
1556
|
+
const fiber = fibers.get(runId);
|
|
1557
|
+
// Cancellation is fiber interruption, not a flag anyone polls.
|
|
1558
|
+
if (fiber !== undefined)
|
|
1559
|
+
yield* Fiber.interrupt(fiber);
|
|
1560
|
+
if (fibers.get(runId) === fiber)
|
|
1561
|
+
fibers.delete(runId);
|
|
1562
|
+
// Only reconciliation holds the writer. Re-read after finalizers and
|
|
1563
|
+
// retain the original fence so cleanup cannot transfer this cancel to
|
|
1564
|
+
// a replacement owner or overwrite a terminal outcome.
|
|
1565
|
+
return yield* settle(writer.write(Effect.gen(function* () {
|
|
1566
|
+
const current = yield* summaryOf(yield* requireRow(runId));
|
|
1567
|
+
if (terminal(current.status))
|
|
1568
|
+
return current;
|
|
1569
|
+
return yield* transition(runId, row.owner, current, "cancelled");
|
|
1570
|
+
})).pipe(Effect.catchTag("@smthrs/database/DatabaseError", (error) => Effect.fail(persistence("settle an interrupted run")(error)))));
|
|
1571
|
+
}),
|
|
1572
|
+
codeDrift: Effect.fn("SqlControlRuntime.codeDrift")(function* (runId) {
|
|
1573
|
+
const summary = yield* recordedCode(yield* requireRow(runId));
|
|
1574
|
+
const current = (yield* readCurrentFlows).get(summary.flowId);
|
|
1575
|
+
const pinned = summary.executionDigest === undefined || options.pinnedFlow === undefined
|
|
1576
|
+
? undefined :
|
|
1577
|
+
yield* options.pinnedFlow(summary.flowId, summary.executionDigest);
|
|
1578
|
+
return codeDriftOf(summary, pinned === true ?
|
|
1579
|
+
{ executionDigest: summary.executionDigest }
|
|
1580
|
+
: pinned === false
|
|
1581
|
+
? undefined
|
|
1582
|
+
: current, options.engineVersion);
|
|
1583
|
+
}),
|
|
1584
|
+
recordedCode: Effect.fn("SqlControlRuntime.recordedCode")(function* (runId) {
|
|
1585
|
+
const { executionDigest, engineVersion } = yield* recordedCode(yield* requireRow(runId));
|
|
1586
|
+
return { executionDigest, engineVersion };
|
|
1587
|
+
}),
|
|
1588
|
+
resume: Effect.fn("SqlControlRuntime.resume")((runId, resumeOptions) => resumeRun(runId, resumeOptions?.scope)),
|
|
1589
|
+
resumeAdopting: Effect.fn("SqlControlRuntime.resumeAdopting")((runId, resumeOptions) => resumeRun(runId, resumeOptions?.scope, adoptable)),
|
|
1590
|
+
claimFence: Effect.fn("SqlControlRuntime.claimFence")(function* (runId) {
|
|
1591
|
+
const row = yield* requireRow(runId);
|
|
1592
|
+
if (!ownedByUs(row))
|
|
1593
|
+
return yield* new ClaimLost({ runId });
|
|
1594
|
+
return JSON.stringify(row.owner);
|
|
1595
|
+
}),
|
|
1596
|
+
releasePending: Effect.fn("SqlControlRuntime.releasePending")(function* (runId, fence) {
|
|
1597
|
+
const row = yield* requireRow(runId);
|
|
1598
|
+
const presented = yield* decodeStoredJson("control fence", Ownership.OwnerId, fence).pipe(Effect.mapError(() => new ClaimLost({ runId })));
|
|
1599
|
+
const timestamp = yield* now;
|
|
1600
|
+
const next = {
|
|
1601
|
+
...yield* summaryOf(row),
|
|
1602
|
+
status: "accepted",
|
|
1603
|
+
ownerId: undefined,
|
|
1604
|
+
parkedBy: undefined,
|
|
1605
|
+
updatedAt: timestamp
|
|
1606
|
+
};
|
|
1607
|
+
const outcome = yield* runStore.transitionOwned(runId, presented, "suspended", JSON.stringify(next)).pipe(Effect.mapError(persistence("release a pending run")));
|
|
1608
|
+
if (outcome._tag === "NotFound")
|
|
1609
|
+
return yield* new RunNotFound({ runId });
|
|
1610
|
+
if (outcome._tag !== "Transitioned")
|
|
1611
|
+
return yield* new ClaimLost({ runId });
|
|
1612
|
+
return next;
|
|
1613
|
+
}),
|
|
1614
|
+
writeStatus: Effect.fn("SqlControlRuntime.writeStatus")(function* (runId, fence, status) {
|
|
1615
|
+
const row = yield* requireRow(runId);
|
|
1616
|
+
const presented = yield* decodeStoredJson("control fence", Ownership.OwnerId, fence).pipe(Effect.mapError(() => new ClaimLost({ runId })));
|
|
1617
|
+
return yield* transition(runId, presented, yield* summaryOf(row), status);
|
|
1618
|
+
}),
|
|
1619
|
+
/**
|
|
1620
|
+
* The submitted identity wins, and only the clock is the runtime's.
|
|
1621
|
+
*
|
|
1622
|
+
* `Control.RunMutationInput` states the order: the runtime "supplies its
|
|
1623
|
+
* own principal when the caller names none". The submitted one is the
|
|
1624
|
+
* identity the server authenticated at its boundary, so a composition
|
|
1625
|
+
* default that overrode it would rename every remote operator to
|
|
1626
|
+
* whatever this process was built with.
|
|
1627
|
+
*/
|
|
1628
|
+
stampPrincipal: Effect.fn("SqlControlRuntime.stampPrincipal")(function* (submitted) {
|
|
1629
|
+
const timestamp = yield* now;
|
|
1630
|
+
return {
|
|
1631
|
+
id: submitted?.id ?? options.principal?.id ?? "local",
|
|
1632
|
+
kind: submitted?.kind ?? options.principal?.kind ?? "operator",
|
|
1633
|
+
stampedAt: timestamp
|
|
1634
|
+
};
|
|
1635
|
+
}),
|
|
1636
|
+
lookupMutation: Effect.fn("SqlControlRuntime.lookupMutation")(function* (key, fingerprint) {
|
|
1637
|
+
const rows = yield* sql `
|
|
1638
|
+
SELECT fingerprint, receipt_json AS "receiptJson" FROM control_mutations WHERE mutation_key = ${key}
|
|
1639
|
+
`.pipe(query("read a mutation"));
|
|
1640
|
+
const row = rows[0];
|
|
1641
|
+
if (row === undefined)
|
|
1642
|
+
return undefined;
|
|
1643
|
+
if (row.fingerprint !== fingerprint) {
|
|
1644
|
+
return { _tag: "Conflict", message: `idempotency key ${key} was used for another mutation` };
|
|
1645
|
+
}
|
|
1646
|
+
const receipt = yield* decodeStoredJson("control_mutations.receipt_json", Receipt, row.receiptJson);
|
|
1647
|
+
return alreadyApplied(key, receipt);
|
|
1648
|
+
}),
|
|
1649
|
+
recordMutation: Effect.fn("SqlControlRuntime.recordMutation")((key, fingerprint, receipt) => writer.write(Effect.gen(function* () {
|
|
1650
|
+
yield* sql `
|
|
1651
|
+
INSERT INTO control_mutations (mutation_key, fingerprint, receipt_json)
|
|
1652
|
+
VALUES (${key}, ${fingerprint}, ${JSON.stringify(receipt)})
|
|
1653
|
+
ON CONFLICT (mutation_key) DO NOTHING
|
|
1654
|
+
`;
|
|
1655
|
+
const rows = yield* sql `
|
|
1656
|
+
SELECT fingerprint, receipt_json AS "receiptJson"
|
|
1657
|
+
FROM control_mutations WHERE mutation_key = ${key}
|
|
1658
|
+
`;
|
|
1659
|
+
const stored = rows[0];
|
|
1660
|
+
if (stored === undefined || stored.fingerprint !== fingerprint || stored.receiptJson !== JSON.stringify(receipt)) {
|
|
1661
|
+
return yield* Effect.fail(new PersistenceError({
|
|
1662
|
+
operation: "record a mutation",
|
|
1663
|
+
message: `Idempotency key ${key} was already settled by another mutation`
|
|
1664
|
+
}));
|
|
1665
|
+
}
|
|
1666
|
+
})).pipe(Effect.asVoid, Effect.mapError((cause) => cause instanceof PersistenceError ? cause : persistence("record a mutation")(cause)))),
|
|
1667
|
+
claimRunKey: Effect.fn("SqlControlRuntime.claimRunKey")((key, fingerprint) => {
|
|
1668
|
+
const claimant = randomId();
|
|
1669
|
+
return writer.write(Effect.gen(function* () {
|
|
1670
|
+
yield* sql `
|
|
1671
|
+
INSERT INTO control_run_keys (idempotency_key, fingerprint, claimant)
|
|
1672
|
+
VALUES (${key}, ${fingerprint}, ${claimant})
|
|
1673
|
+
ON CONFLICT (idempotency_key) DO NOTHING
|
|
1674
|
+
`;
|
|
1675
|
+
const holders = yield* sql `
|
|
1676
|
+
SELECT fingerprint, claimant FROM control_run_keys
|
|
1677
|
+
WHERE idempotency_key = ${key}
|
|
1678
|
+
`;
|
|
1679
|
+
const holder = holders[0];
|
|
1680
|
+
if (holder === undefined) {
|
|
1681
|
+
return yield* new PersistenceError({
|
|
1682
|
+
operation: "claim a run key",
|
|
1683
|
+
message: `Run key ${key} disappeared while it was being claimed`
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
if (holder.fingerprint !== fingerprint) {
|
|
1687
|
+
return yield* new InvalidInput({
|
|
1688
|
+
issue: `idempotency key ${key} was used for another run`
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
if (holder.claimant === claimant)
|
|
1692
|
+
return { _tag: "Claimed" };
|
|
1693
|
+
// Serialized writers make the winner's key and receipt visible in
|
|
1694
|
+
// one commit. Seeing its key without its receipt is therefore
|
|
1695
|
+
// corruption (or a claim written by an older, non-atomic build), not
|
|
1696
|
+
// permission to launch a second run.
|
|
1697
|
+
const rows = yield* sql `
|
|
1698
|
+
SELECT fingerprint, receipt_json AS "receiptJson"
|
|
1699
|
+
FROM control_mutations WHERE mutation_key = ${key}
|
|
1700
|
+
`;
|
|
1701
|
+
const record = rows[0];
|
|
1702
|
+
if (record === undefined || record.fingerprint !== fingerprint) {
|
|
1703
|
+
return yield* new PersistenceError({
|
|
1704
|
+
operation: "claim a run key",
|
|
1705
|
+
message: `Run key ${key} has no matching settled receipt`
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
const receipt = yield* decodeStoredJson("control_mutations.receipt_json", Receipt, record.receiptJson);
|
|
1709
|
+
return { _tag: "Raced", receipt };
|
|
1710
|
+
})).pipe(Effect.mapError((cause) => cause instanceof InvalidInput || cause instanceof PersistenceError
|
|
1711
|
+
? cause
|
|
1712
|
+
: persistence("claim a run key")(cause)));
|
|
1713
|
+
}),
|
|
1714
|
+
releaseRunKey: Effect.fn("SqlControlRuntime.releaseRunKey")((key) => writer.write(sql `
|
|
1715
|
+
DELETE FROM control_run_keys WHERE idempotency_key = ${key}
|
|
1716
|
+
`).pipe(Effect.asVoid, Effect.mapError(persistence("release a run key")))),
|
|
1717
|
+
grants: Effect.fn("SqlControlRuntime.grants")(() => sql `
|
|
1718
|
+
SELECT token_id AS "tokenId", envelope_json AS "envelopeJson",
|
|
1719
|
+
scope, installed_at_ms AS "installedAtMs"
|
|
1720
|
+
FROM control_grants ORDER BY installed_at_ms, target_tag, run_id, target_id
|
|
1721
|
+
`.pipe(query("list grants"), Effect.flatMap((rows) => Effect.forEach(rows, (row) => Effect.all({
|
|
1722
|
+
envelope: decodeStoredJson("control_grants.envelope_json", Envelope, row.envelopeJson),
|
|
1723
|
+
scope: decodeStoredValue("control_grants.scope", GrantScope, row.scope)
|
|
1724
|
+
}).pipe(Effect.map(({ envelope, scope }) => ({
|
|
1725
|
+
tokenId: row.tokenId,
|
|
1726
|
+
envelope,
|
|
1727
|
+
scope,
|
|
1728
|
+
installedAt: Number(row.installedAtMs)
|
|
1729
|
+
})))))))()
|
|
1730
|
+
});
|
|
1731
|
+
return service;
|
|
1732
|
+
});
|
|
1733
|
+
};
|
|
1734
|
+
/**
|
|
1735
|
+
* Provides a durable runtime over the ambient database and run store.
|
|
1736
|
+
*
|
|
1737
|
+
* @category layers
|
|
1738
|
+
* @since 0.1.0
|
|
1739
|
+
*/
|
|
1740
|
+
export const layer = (options = {}) => Layer.effect(ControlRuntime)(makeRuntime(options));
|
|
1741
|
+
/**
|
|
1742
|
+
* Provides a durable runtime and the run store it needs over the ambient
|
|
1743
|
+
* database.
|
|
1744
|
+
*
|
|
1745
|
+
* @category layers
|
|
1746
|
+
* @since 0.1.0
|
|
1747
|
+
*/
|
|
1748
|
+
export const layerWithStore = (options = {}) => layer(options).pipe(Layer.provideMerge(RunStore.layer));
|
|
1749
|
+
/**
|
|
1750
|
+
* Constructs a durable runtime over the ambient database and run store.
|
|
1751
|
+
*
|
|
1752
|
+
* @category constructors
|
|
1753
|
+
* @since 0.1.0
|
|
1754
|
+
*/
|
|
1755
|
+
export { makeRuntime as make };
|
|
1756
|
+
//# sourceMappingURL=SqlControlRuntime.js.map
|