@starci/hfs 1.0.1 → 2.0.1
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 +51 -0
- package/README.md +110 -12
- package/bin/hfs.mjs +119 -15
- package/emit/compiler.mjs +35 -0
- package/emit/contracts.mjs +97 -0
- package/emit/operations-worker.mjs +24 -0
- package/emit/operations.mjs +126 -0
- package/emit/schema-worker.mjs +117 -0
- package/emit/static-graph.mjs +670 -0
- package/emit/type-schema.mjs +145 -0
- package/package.json +4 -1
- package/report/sonar.mjs +180 -0
- package/runtime/engine/admission.mjs +284 -0
- package/runtime/engine/digest.mjs +10 -0
- package/runtime/engine/ledger-db.mjs +1245 -0
- package/runtime/engine/machine-db.mjs +1484 -0
- package/runtime/engine/migrations/machine/0001-init.sql +887 -0
- package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
- package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
- package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
- package/runtime/knowledge/hfs/slots.yaml +405 -137
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/knowledge/sonar-gate.yaml +85 -0
- package/runtime/modules/kernel/failure-codes.yaml +1480 -16
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +316 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +89 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/architecture.mjs +41 -0
- package/runtime/scripts/checks/common.mjs +37 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/artifact-hold.mjs +89 -0
- package/runtime/scripts/lib/artifact-store.mjs +103 -0
- package/runtime/scripts/lib/fs-kind.mjs +10 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-check.mjs +254 -28
- package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
- package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
- package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
- package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
- package/runtime/scripts/lib/hfs-slots.mjs +88 -40
- package/runtime/scripts/lib/hfs-tree.mjs +80 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +2 -0
- package/runtime/scripts/lib/redact.mjs +148 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/safe-remove.mjs +179 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/sleep-sync.mjs +17 -0
- package/runtime/scripts/lib/stack-declaration.mjs +52 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/scaffold/service.mjs +333 -0
- package/sync/format.mjs +46 -0
- package/sync/hygiene.mjs +56 -24
- package/sync/index.mjs +133 -41
- package/sync/managed.mjs +170 -0
- package/sync/skeleton.mjs +32 -10
- package/sync/sonar-key.mjs +13 -0
- package/sync/ts-strict.mjs +48 -0
- package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
- package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
- package/templates/be/hooks/husky/pre-commit +13 -0
- package/templates/be/hooks/husky/pre-push +7 -0
- package/templates/be/package-scripts/package.json +22 -0
- package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
- package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
- package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
- package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
- package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
- package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
- package/templates/be/tool-config/eslint.config.mjs +3 -0
- package/templates/be/tool-config/jest.config.js +1 -0
- package/templates/be/tool-config/prettierignore +8 -0
- package/templates/be/tool-config/prettierrc +1 -0
- package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
- package/templates/be/tool-config/tsconfig.build.json +5 -0
- package/templates/be/tool-config/tsconfig.json +11 -0
- package/templates/common/gitignore.base +1 -1
- package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
- package/templates/fe/hooks/husky/pre-commit +16 -0
- package/templates/fe/hooks/husky/pre-push +6 -0
- package/templates/fe/package-scripts/package.json +17 -0
- package/templates/fe/parts/api-client.ts +44 -0
- package/templates/fe/parts/api-outcome.ts +7 -0
- package/templates/fe/quality-config/sonar-project.properties +8 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
- package/templates/fe/tool-config/eslint.config.mjs +3 -0
- package/templates/fe/tool-config/prettierignore +10 -0
- package/templates/fe/tool-config/prettierrc +1 -0
- package/templates/fe/tool-config/stylelint.config.mjs +3 -0
- package/templates/fe/tool-config/tsconfig.json +4 -0
- package/templates/be/pre-commit +0 -8
- package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
- package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
- package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
- package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
- package/templates/common/codecov.yml +0 -13
- package/templates/common/pre-push +0 -5
- package/templates/fe/e2e.yml +0 -22
- package/templates/fe/pre-commit +0 -7
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
- package/templates/fe/sonar-project.properties +0 -11
- /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
- /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import {createRequire} from 'node:module';
|
|
6
|
+
import {findOwnedPathLeaseConflicts} from './admission.mjs';
|
|
7
|
+
import {sha256} from './digest.mjs';
|
|
8
|
+
import {SETTLED_JOB_LIST} from './admission.mjs';
|
|
9
|
+
import {putBlob,blobPath} from '../scripts/lib/artifact-store.mjs';
|
|
10
|
+
import {redactData,redactText} from '../scripts/lib/redact.mjs';
|
|
11
|
+
import {isBusyError,isUnderTempDir,localProjectsRoot,machineFileFor,newSpanId,newTraceId,readMachine,starciLocalRoot,TEST_REGISTRY_ENV,withMachine} from './machine-db.mjs';
|
|
12
|
+
// The machine-side path helpers have one definition (engine/machine-db.mjs); re-exported for the ledger's callers.
|
|
13
|
+
export {isUnderTempDir,machineFileFor,starciLocalRoot,TEST_REGISTRY_ENV};
|
|
14
|
+
const require=createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* runtime.sqlite — the ONE writer (and the reader opener) of a project's runtime ledger (DBTREE.sql PHẦN A,
|
|
18
|
+
* ARCHITECTURE-DB §4, RESEARCH-STORAGE §3). The DDL is engine/migrations/runtime/0001-init.sql, executed as is:
|
|
19
|
+
* STRICT tables, state machines, append-only guards and views live IN the database, so an invalid write is refused
|
|
20
|
+
* at the write whatever code issued it. Clean slate (alpha.3): a file that is not 'starci/runtime@1' is refused;
|
|
21
|
+
* there is no migrator, no backfill and no legacy table. A fresh ledger is created by openLedger on first use at
|
|
22
|
+
* the file ledgerFileFor(<repo root>) resolves.
|
|
23
|
+
*
|
|
24
|
+
* Every write to runtime.sqlite goes through the typed functions below (`write.*` on the handle, or the exported
|
|
25
|
+
* functions taking a db inside a transaction). Each state change appends exactly one events row in the same
|
|
26
|
+
* transaction; the events hash chain (digest) is computed here, in JS.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
30
|
+
// Vocabulary
|
|
31
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
32
|
+
export const newToken=()=>crypto.randomBytes(24).toString('hex');
|
|
33
|
+
/** The TRACEPARENT an op receives: 00-<trace>-<span>-01. */
|
|
34
|
+
export const traceparent=(traceId,spanId)=>`00-${traceId}-${spanId}-01`;
|
|
35
|
+
/**
|
|
36
|
+
* jobs.status (DBTREE A4 job_transitions), grouped by what a row in that state still owes: `dispatchable` rows are the
|
|
37
|
+
* live frontier, `awaiting` wait on a settle or decision, `fenced` keeps a launch whose effect is unproven, `settled`
|
|
38
|
+
* holds nothing the runtime still needs. The SQL CHECK and job_transitions are the authority; this is their mirror.
|
|
39
|
+
*/
|
|
40
|
+
export const JOB_STATUSES=Object.freeze({
|
|
41
|
+
dispatchable:Object.freeze(['queued','ready','leased','running','answering']),
|
|
42
|
+
awaiting:Object.freeze(['reported','deciding']),
|
|
43
|
+
fenced:Object.freeze(['effect_unknown']),
|
|
44
|
+
settled:SETTLED_JOB_LIST,
|
|
45
|
+
});
|
|
46
|
+
export const SETTLED_JOB_STATUSES=JOB_STATUSES.settled;
|
|
47
|
+
export const WORKFLOW_PHASES=Object.freeze(['awaiting-approval','queued','running','paused','stopped','finished','archived']);
|
|
48
|
+
export const UNIT_STATES=Object.freeze(['planned','queued','running','reported','deciding','done','failed','dropped']);
|
|
49
|
+
export const DEFAULT_TRY_BUDGET=5;
|
|
50
|
+
export const JOB_ARTIFACT_KINDS=Object.freeze(['diff','patch','image','video','report','log','trace','file']);
|
|
51
|
+
export const JOB_ARTIFACT_SUBKINDS=Object.freeze(['draw-render','asset-gen','app-capture','e2e-capture','uat-capture','uat-video','e2e-video',
|
|
52
|
+
'playwright-trace','patch','patch-json','diff','report','log','critique','metrics','grammar-proposal','asset-request','terminal-transcript','cli-transcript']);
|
|
53
|
+
export const JOB_ARTIFACT_ROLES=Object.freeze(['check-output','check-stdout','check-stderr','patch','diff','report-attachment','log',
|
|
54
|
+
'direction','prompt','render','redline','critique','capture','dom','screenshot','video','trace','uat-run','metrics','salvage','scan','other']);
|
|
55
|
+
|
|
56
|
+
export const LEDGER_SCHEMA='starci/runtime@1';
|
|
57
|
+
export const LEDGER_VERSION=4;
|
|
58
|
+
|
|
59
|
+
const need=(ok,message,code)=>{if(!ok)throw Object.assign(Error(message),code?{code}:{});};
|
|
60
|
+
const json=value=>value===undefined||value===null?null:JSON.stringify(value);
|
|
61
|
+
const parseJson=text=>text===null||text===undefined?null:JSON.parse(text);
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
64
|
+
// Paths (decision Q1: runtime.sqlite lives OUT of .starciwork, at %LOCALAPPDATA%/StarCi/projects/<ledger_id>/)
|
|
65
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
66
|
+
/**
|
|
67
|
+
* The runtime tree is never a Work root of its own: a project's Work root is its backend, reached through
|
|
68
|
+
* `.workspaces`. A ledger for a root inside the runtime checkout is refused by name.
|
|
69
|
+
*/
|
|
70
|
+
const RUNTIME_MARKER=root=>fs.existsSync(path.join(root,'bin','starci.mjs'))
|
|
71
|
+
&&fs.existsSync(path.join(root,'engine','ledger-db.mjs'));
|
|
72
|
+
export const isRuntimeRoot=root=>RUNTIME_MARKER(path.resolve(root));
|
|
73
|
+
/** Overrides the projects root (the directory holding <ledger_id>/runtime.sqlite) for this process tree; narrower than machine-db.mjs LOCAL_ROOT_ENV, which this still honors through starciLocalRoot when unset. */
|
|
74
|
+
export const PROJECTS_ROOT_ENV='STARCI_PROJECTS_ROOT';
|
|
75
|
+
const normDir=file=>path.resolve(String(file)).replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
|
76
|
+
/** %LOCALAPPDATA%/StarCi/projects (starciLocalRoot, itself overridable by STARCI_LOCAL_ROOT; a node --test process tree gets one under the OS temp directory). */
|
|
77
|
+
export const projectsRootFor=(env=process.env)=>{
|
|
78
|
+
if(env[PROJECTS_ROOT_ENV])return path.resolve(env[PROJECTS_ROOT_ENV]);
|
|
79
|
+
const root=localProjectsRoot(env);
|
|
80
|
+
if(env.NODE_TEST_CONTEXT&&!isUnderTempDir(root,{env}))return path.join(os.tmpdir(),'starci-test-projects');
|
|
81
|
+
return root;
|
|
82
|
+
};
|
|
83
|
+
/** The normalized identity of a repository root: resolved, realpath when it exists, forward slashes, lower case. */
|
|
84
|
+
export const repoRootKey=repoRoot=>{let root=path.resolve(repoRoot);try{root=fs.realpathSync.native(root);}catch{}return normDir(root);};
|
|
85
|
+
const UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
86
|
+
/**
|
|
87
|
+
* The ledger id of a repository root until the machine registry (a3-2 machine.ledgers) resolves it: a name-based
|
|
88
|
+
* UUID of the normalized root, so the same root always names the same ledger and no side registry is needed.
|
|
89
|
+
*/
|
|
90
|
+
export const ledgerIdForRepo=repoRoot=>{
|
|
91
|
+
const h=sha256(`starci-ledger:${repoRootKey(repoRoot)}`);
|
|
92
|
+
return `${h.slice(0,8)}-${h.slice(8,12)}-5${h.slice(13,16)}-${(8|(parseInt(h[16],16)&3)).toString(16)}${h.slice(17,20)}-${h.slice(20,32)}`;
|
|
93
|
+
};
|
|
94
|
+
// file → repo root, for the meta seed (and registration) of a ledger created through ledgerFileFor.
|
|
95
|
+
const repoRootOfFile=new Map();
|
|
96
|
+
const resolvedFiles=new Map();
|
|
97
|
+
/** The registered runtime.sqlite of `repoRoot` in machine.ledgers (a3-2 resolveLedger), or null. */
|
|
98
|
+
function registeredLedgerFile(root,env){
|
|
99
|
+
try{
|
|
100
|
+
const row=readMachine(m=>m.resolveLedger({repoRoot:root}),null,{env});
|
|
101
|
+
return row&&row.state!=='retired'&&row.file?path.resolve(row.file):null;
|
|
102
|
+
}catch{return null;}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The runtime.sqlite of a project, by its Work-root repository (decision Q1): the file machine.ledgers registers for
|
|
106
|
+
* that root; an unregistered root gets <projects root>/<ledgerIdForRepo(root)>/runtime.sqlite, which openLedger creates
|
|
107
|
+
* (meta.ledger_id = that id, meta.repo_root = the root) and registers in machine.ledgers. Resolved once per process.
|
|
108
|
+
*/
|
|
109
|
+
export const ledgerFileFor=(repoRoot,{env=process.env}={})=>{
|
|
110
|
+
if(typeof repoRoot!=='string'||!repoRoot.trim())throw Error('ledgerFileFor needs a repository root');
|
|
111
|
+
const root=path.resolve(repoRoot);
|
|
112
|
+
if(isRuntimeRoot(root))throw Object.assign(Error(`ledger-root-is-runtime: ${root} is the StarCi runtime, not a Work root; route the project through .workspaces`),{code:'STARCI_LEDGER_ROOT_IS_RUNTIME'});
|
|
113
|
+
const key=`${repoRootKey(root)}\n${projectsRootFor(env)}`;
|
|
114
|
+
let file=resolvedFiles.get(key);
|
|
115
|
+
if(!file){
|
|
116
|
+
file=registeredLedgerFile(root,env)??path.join(projectsRootFor(env),ledgerIdForRepo(root),'runtime.sqlite');
|
|
117
|
+
if(fs.existsSync(file))resolvedFiles.set(key,file);
|
|
118
|
+
}
|
|
119
|
+
repoRootOfFile.set(path.resolve(file),root);
|
|
120
|
+
return file;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
124
|
+
// Connection policy (DBTREE header "PRAGMA lúc mở", RESEARCH-STORAGE §3)
|
|
125
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
126
|
+
const INIT_SQL_FILE=new URL('./migrations/runtime/0001-init.sql',import.meta.url);
|
|
127
|
+
const INIT_SQL=fs.readFileSync(INIT_SQL_FILE,'utf8');
|
|
128
|
+
const INIT_SQL_SHA=sha256(INIT_SQL);
|
|
129
|
+
/** Forward migrations after 0001-init, in order; each bumps user_version to its `version` (migrateLedger). */
|
|
130
|
+
const FORWARD_MIGRATIONS=Object.freeze([{version:3,name:'0003-usage-unavailable'},{version:4,name:'0004-attempt-why'}]);
|
|
131
|
+
export const LEDGER_BUSY_TIMEOUT_MS=15000;
|
|
132
|
+
/**
|
|
133
|
+
* Writer pragmas. wal_autocheckpoint=0 on EVERY connection except the one checkpointer (openLedger({checkpointer:true}),
|
|
134
|
+
* the reconciler engine): SQLite 3.50.4 (node:sqlite of Node 25.2.1) sits in the WAL-reset bug range 3.7.0–3.51.2 when two
|
|
135
|
+
* connections checkpoint concurrently.
|
|
136
|
+
*/
|
|
137
|
+
export const LEDGER_PRAGMAS=Object.freeze({synchronous:'NORMAL',foreign_keys:'ON',temp_store:'MEMORY',cache_size:-16000,
|
|
138
|
+
journal_size_limit:67108864,trusted_schema:'OFF',wal_autocheckpoint:0});
|
|
139
|
+
export const CHECKPOINTER_AUTOCHECKPOINT=8000;
|
|
140
|
+
const READ_PRAGMAS=Object.freeze({query_only:'ON',temp_store:'MEMORY',cache_size:-16000,trusted_schema:'OFF'});
|
|
141
|
+
const applyPragmas=(db,pragmas)=>db.exec(Object.entries(pragmas).map(([k,v])=>`PRAGMA ${k}=${v};`).join(' '));
|
|
142
|
+
/** The pragma values a handle actually runs with. */
|
|
143
|
+
export const connectionFacts=db=>({journalMode:String(db.prepare('PRAGMA journal_mode').get().journal_mode).toLowerCase(),
|
|
144
|
+
synchronous:Number(db.prepare('PRAGMA synchronous').get().synchronous),busyTimeoutMs:Number(db.prepare('PRAGMA busy_timeout').get().timeout),
|
|
145
|
+
tempStore:Number(db.prepare('PRAGMA temp_store').get().temp_store),cacheSize:Number(db.prepare('PRAGMA cache_size').get().cache_size),
|
|
146
|
+
walAutocheckpoint:Number(db.prepare('PRAGMA wal_autocheckpoint').get().wal_autocheckpoint),
|
|
147
|
+
foreignKeys:Number(db.prepare('PRAGMA foreign_keys').get().foreign_keys),queryOnly:Number(db.prepare('PRAGMA query_only').get().query_only)});
|
|
148
|
+
/** BEGIN IMMEDIATE: spin for `spinMs` without the busy handler's 15 ms sleeps, then wait with the connection's busy_timeout. */
|
|
149
|
+
export const LEDGER_SPIN_MS=20;
|
|
150
|
+
export function beginImmediate(db,{spinMs=LEDGER_SPIN_MS}={}){
|
|
151
|
+
if(spinMs>0){
|
|
152
|
+
const busyTimeoutMs=Number(db.prepare('PRAGMA busy_timeout').get()?.timeout??LEDGER_BUSY_TIMEOUT_MS);
|
|
153
|
+
db.exec('PRAGMA busy_timeout=0');
|
|
154
|
+
try{
|
|
155
|
+
const until=performance.now()+spinMs;
|
|
156
|
+
for(;;){
|
|
157
|
+
try{db.exec('BEGIN IMMEDIATE');return;}
|
|
158
|
+
catch(error){if(!isBusyError(error))throw error;if(performance.now()>=until)break;}
|
|
159
|
+
}
|
|
160
|
+
}finally{db.exec(`PRAGMA busy_timeout=${Math.trunc(busyTimeoutMs)}`);}
|
|
161
|
+
}
|
|
162
|
+
db.exec('BEGIN IMMEDIATE');
|
|
163
|
+
}
|
|
164
|
+
let openLedgerTransactions=0;
|
|
165
|
+
/** Ledger write transactions open in THIS process (the typed-log writer never flushes inside one). */
|
|
166
|
+
export const ledgerTransactionDepth=()=>openLedgerTransactions;
|
|
167
|
+
const openSleep=ms=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);
|
|
168
|
+
// SQLITE_CANTOPEN is transient on Windows while a concurrent process closes the WAL files: a short bounded retry.
|
|
169
|
+
const OPEN_RETRY_DELAYS_MS=[0,300,900];
|
|
170
|
+
const cantOpen=error=>/unable to open/i.test(String(error?.message??''));
|
|
171
|
+
function openDb({file,busyTimeoutMs,journalMode='WAL',autoVacuum=false,label,pragmas=LEDGER_PRAGMAS}){
|
|
172
|
+
const {DatabaseSync}=require('node:sqlite');
|
|
173
|
+
need(typeof file==='string'&&file.trim(),`${label} needs a file`);
|
|
174
|
+
fs.mkdirSync(path.dirname(path.resolve(file)),{recursive:true});
|
|
175
|
+
let lastError;
|
|
176
|
+
for(const delay of OPEN_RETRY_DELAYS_MS){
|
|
177
|
+
if(delay)openSleep(delay);
|
|
178
|
+
let db;
|
|
179
|
+
try{
|
|
180
|
+
db=new DatabaseSync(file,{timeout:busyTimeoutMs});
|
|
181
|
+
// page_size/auto_vacuum only take on an empty database, before WAL and before the first table.
|
|
182
|
+
if(autoVacuum&&Number(db.prepare('PRAGMA page_count').get().page_count)===0)db.exec('PRAGMA page_size=4096; PRAGMA auto_vacuum=INCREMENTAL;');
|
|
183
|
+
const sqliteVersion=db.prepare('select sqlite_version() AS version').get().version;
|
|
184
|
+
const actual=String(db.prepare(`PRAGMA journal_mode=${journalMode}`).get().journal_mode).toUpperCase();
|
|
185
|
+
need(actual===String(journalMode).toUpperCase(),`${label}: SQLite selected journal mode ${actual}, expected ${journalMode} (a network or UNC path cannot hold WAL)`,'STARCI_LEDGER_NOT_WAL');
|
|
186
|
+
applyPragmas(db,pragmas);
|
|
187
|
+
return {db,sqliteVersion,journalMode:actual};
|
|
188
|
+
}catch(error){
|
|
189
|
+
try{db?.close();}catch{}
|
|
190
|
+
if(!cantOpen(error))throw error;
|
|
191
|
+
lastError=error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
throw lastError;
|
|
195
|
+
}
|
|
196
|
+
// handle.transaction(fn): BEGIN IMMEDIATE … COMMIT; a nested call throws. transaction.active() tells whether one is open,
|
|
197
|
+
// so the handle's one-call writes (write.*, ensureWorkflow, appendEvent, enqueueJob) join an open transaction instead.
|
|
198
|
+
const makeTransaction=(db,label)=>{let inside=false;const tx=fn=>{if(inside)throw Error(`${label}-nested-transaction`);inside=true;try{beginImmediate(db);}catch(error){inside=false;throw error;}openLedgerTransactions++;try{const result=fn(db);db.exec('COMMIT');return result;}catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{inside=false;openLedgerTransactions--;}};tx.active=()=>inside;return tx;};
|
|
199
|
+
const userVersion=db=>Number(db.prepare('PRAGMA user_version').get().user_version);
|
|
200
|
+
const metaOf=db=>Object.fromEntries(db.prepare('SELECT key,value FROM meta').all().map(row=>[row.key,row.value]));
|
|
201
|
+
const hasTable=(db,name)=>Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name));
|
|
202
|
+
/** True when the ledger `db` holds `table`. */
|
|
203
|
+
export const hasLedgerTable=hasTable;
|
|
204
|
+
/** True when `table` of the ledger `db` has `column`. */
|
|
205
|
+
export const hasLedgerColumn=(db,table,column)=>db.prepare(`PRAGMA table_info(${table})`).all().some(c=>c.name===column);
|
|
206
|
+
const versionTuple=v=>String(v).split('.').map(Number);
|
|
207
|
+
const olderThan=(a,b)=>{const x=versionTuple(a),y=versionTuple(b);for(let i=0;i<Math.max(x.length,y.length);i++){if((x[i]??0)!==(y[i]??0))return (x[i]??0)<(y[i]??0);}return false;};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Refuse any file that is not a starci/runtime@1 ledger at user_version 1 (clean slate: no migration). Also refuses a
|
|
211
|
+
* running SQLite older than the one the ledger recorded.
|
|
212
|
+
*/
|
|
213
|
+
function verifyLedger(db,{file,sqliteVersion}){
|
|
214
|
+
const version=userVersion(db);
|
|
215
|
+
const legacy=hasTable(db,'meta')?metaOf(db).schema:(hasTable(db,'jobs')||hasTable(db,'events')?'pre-meta':null);
|
|
216
|
+
need(version>=1&&version<=LEDGER_VERSION&&legacy===LEDGER_SCHEMA,
|
|
217
|
+
`ledger-schema-refused: ${file} is ${legacy??'not a StarCi ledger'} at user_version ${version}, this runtime opens only ${LEDGER_SCHEMA} at user_version 1..${LEDGER_VERSION} (an older one is migrated forward on the first writer open); a fresh runtime.sqlite is created by openLedger on first use at the file ledgerFileFor(<repo root>) resolves — move the refused file aside to let one be created`,'STARCI_LEDGER_SCHEMA_REFUSED');
|
|
218
|
+
const recorded=metaOf(db).sqlite_version;
|
|
219
|
+
need(!recorded||!olderThan(sqliteVersion,recorded),`ledger-sqlite-downgrade: ${file} was last opened by SQLite ${recorded}, this process runs ${sqliteVersion}`,'STARCI_LEDGER_SQLITE_DOWNGRADE');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The one in-place schema step a ledger created before `awaiting_owner` needs (a try that ends asking the owner is not
|
|
224
|
+
* `failed`): jobs.status CHECK gains the value, the three job triggers are recreated from 0001-init.sql, and the
|
|
225
|
+
* job_transitions / ui_state_map rows are added. A CHECK cannot be altered, so the table's stored DDL is edited with
|
|
226
|
+
* writable_schema and schema_version is bumped (the SQLite ALTER TABLE recipe for relaxing a constraint) inside one
|
|
227
|
+
* immediate transaction; other connections re-read the schema on their next statement. A ledger created by this
|
|
228
|
+
* runtime already carries it and is left alone. Recorded as schema_migrations version 2.
|
|
229
|
+
*/
|
|
230
|
+
const JOB_STATUS_CHECK_OLD="'effect_unknown','succeeded','failed','cancelled'";
|
|
231
|
+
const JOB_STATUS_CHECK_NEW="'effect_unknown','awaiting_owner','succeeded','failed','cancelled'";
|
|
232
|
+
const JOB_TRIGGERS=['jobs_enqueue_guard','jobs_status_guard','jobs_release_leases'];
|
|
233
|
+
const TRIGGER_END=`\n END;`;
|
|
234
|
+
const triggerSql=name=>{const head=`CREATE TRIGGER IF NOT EXISTS ${name} `;const at=INIT_SQL.indexOf(head);need(at>=0,`0001-init.sql has no trigger ${name}`);const end=INIT_SQL.indexOf(TRIGGER_END,at);need(end>at,`0001-init.sql trigger ${name} has no END`);return INIT_SQL.slice(at,end+TRIGGER_END.length);};
|
|
235
|
+
const jobsDdl=db=>db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='jobs'").get()?.sql??'';
|
|
236
|
+
function upgradeAwaitingOwner(db,{now}){
|
|
237
|
+
if(jobsDdl(db).includes("'awaiting_owner'"))return false;
|
|
238
|
+
beginImmediate(db);
|
|
239
|
+
try{
|
|
240
|
+
const ddl=jobsDdl(db);
|
|
241
|
+
if(ddl.includes("'awaiting_owner'")){db.exec('ROLLBACK');return false;}
|
|
242
|
+
need(ddl.includes(JOB_STATUS_CHECK_OLD),'ledger jobs table has an unrecognised status CHECK; cannot add awaiting_owner','STARCI_LEDGER_SCHEMA_REFUSED');
|
|
243
|
+
const startedAt=now();
|
|
244
|
+
const cookie=Number(db.prepare('PRAGMA schema_version').get().schema_version);
|
|
245
|
+
db.enableDefensive(false);
|
|
246
|
+
try{
|
|
247
|
+
db.exec('PRAGMA writable_schema=ON');
|
|
248
|
+
db.prepare("UPDATE sqlite_master SET sql=? WHERE type='table' AND name='jobs'").run(ddl.replace(JOB_STATUS_CHECK_OLD,JOB_STATUS_CHECK_NEW));
|
|
249
|
+
db.exec(`PRAGMA schema_version=${cookie+1}`);
|
|
250
|
+
db.exec('PRAGMA writable_schema=OFF');
|
|
251
|
+
}finally{db.enableDefensive(true);}
|
|
252
|
+
for(const name of JOB_TRIGGERS)db.exec(`DROP TRIGGER IF EXISTS ${name}`);
|
|
253
|
+
// The asks this ledger already settled `failed` (result verdict awaiting-owner) are waits: move them to the new
|
|
254
|
+
// status (and park their unit deciding) while the transition guard is down, so no pending ask is lost or read failed.
|
|
255
|
+
const asked=db.prepare(`SELECT j.job_id FROM jobs j WHERE j.kind='op' AND j.status='failed' AND CASE WHEN json_valid(
|
|
256
|
+
(SELECT a.settle_json FROM op_attempts a WHERE a.job_id=j.job_id ORDER BY a.attempt_id DESC LIMIT 1))
|
|
257
|
+
THEN json_extract((SELECT a.settle_json FROM op_attempts a WHERE a.job_id=j.job_id ORDER BY a.attempt_id DESC LIMIT 1),'$.verdict') END='awaiting-owner'`).all().map(row=>row.job_id);
|
|
258
|
+
for(const jobId of asked){
|
|
259
|
+
db.prepare("UPDATE jobs SET status='awaiting_owner' WHERE job_id=?").run(jobId);
|
|
260
|
+
db.prepare("UPDATE work_units SET state='deciding' WHERE current_job_id=? AND state='failed'").run(jobId);
|
|
261
|
+
}
|
|
262
|
+
for(const name of JOB_TRIGGERS)db.exec(triggerSql(name));
|
|
263
|
+
db.exec("INSERT OR IGNORE INTO job_transitions VALUES('reported','awaiting_owner'),('deciding','awaiting_owner')");
|
|
264
|
+
db.exec("INSERT OR IGNORE INTO ui_state_map VALUES('job','awaiting_owner','waiting')");
|
|
265
|
+
db.prepare("INSERT OR IGNORE INTO schema_migrations(version,name,runtime_rev,sql_sha256,started_at,finished_at,status) VALUES(2,'0002-awaiting-owner',?,?,?,?,'done')")
|
|
266
|
+
.run(runtimeRev(),INIT_SQL_SHA,startedAt,now());
|
|
267
|
+
db.exec('COMMIT');
|
|
268
|
+
return true;
|
|
269
|
+
}catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Bring an older ledger forward: for each FORWARD_MIGRATIONS entry not yet in schema_migrations - integrity_check and a VACUUM INTO
|
|
274
|
+
* backup (`<file>.pre-<name>.bak`) first, then in one transaction the migration SQL, schema_version+1, user_version, its
|
|
275
|
+
* schema_migrations row, foreign_key_check and quick_check before COMMIT. Idempotent, safe across processes (re-checked under the lock).
|
|
276
|
+
*/
|
|
277
|
+
function migrateLedger(db,{file,now,runtimeRev:rev=null,backup:wantBackup=true}){
|
|
278
|
+
const pending=()=>FORWARD_MIGRATIONS.filter(m=>userVersion(db)<m.version&&!db.prepare('SELECT 1 FROM schema_migrations WHERE version=?').get(m.version));
|
|
279
|
+
if(!pending().length)return [];
|
|
280
|
+
const check=db.prepare('PRAGMA integrity_check').all();
|
|
281
|
+
need(check.length===1&&check[0].integrity_check==='ok',`ledger-migrate-refused: ${file} fails integrity_check`,'STARCI_LEDGER_MIGRATE_REFUSED');
|
|
282
|
+
const applied=[];
|
|
283
|
+
for(const m of pending()){
|
|
284
|
+
const sqlFile=new URL(`./migrations/runtime/${m.name}.sql`,import.meta.url),sql=fs.readFileSync(sqlFile,'utf8');
|
|
285
|
+
const backup=wantBackup?`${path.resolve(file)}.pre-${m.name}.bak`:null; // a ledger just created holds no rows worth a backup
|
|
286
|
+
if(backup&&!fs.existsSync(backup))db.exec(`VACUUM INTO '${backup.replace(/'/g,"''")}'`);
|
|
287
|
+
beginImmediate(db);
|
|
288
|
+
let relaxed=false;
|
|
289
|
+
try{
|
|
290
|
+
if(userVersion(db)>=m.version){db.exec('ROLLBACK');continue;}
|
|
291
|
+
db.enableDefensive(false);relaxed=true; // writable_schema is refused while the connection is defensive
|
|
292
|
+
const started=now();
|
|
293
|
+
db.exec(sql);
|
|
294
|
+
db.exec(`PRAGMA schema_version=${Number(db.prepare('PRAGMA schema_version').get().schema_version)+1}`);
|
|
295
|
+
db.exec('PRAGMA writable_schema=OFF');
|
|
296
|
+
db.exec(`PRAGMA user_version=${m.version}`);
|
|
297
|
+
db.prepare("INSERT INTO schema_migrations(version,name,runtime_rev,sql_sha256,backup_path,started_at,finished_at,status) VALUES(?,?,?,?,?,?,?,'done')")
|
|
298
|
+
.run(m.version,m.name,rev,sha256(sql),backup,started,now());
|
|
299
|
+
need(db.prepare('PRAGMA foreign_key_check').all().length===0,`ledger-migrate-failed: ${m.name} left a foreign key violation`,'STARCI_LEDGER_MIGRATE_FAILED');
|
|
300
|
+
const quick=db.prepare('PRAGMA quick_check').all();
|
|
301
|
+
need(quick.length===1&&quick[0].quick_check==='ok',`ledger-migrate-failed: ${m.name} fails quick_check`,'STARCI_LEDGER_MIGRATE_FAILED');
|
|
302
|
+
db.exec('COMMIT');applied.push(m.name);
|
|
303
|
+
}catch(error){try{db.exec('ROLLBACK');}catch{}try{db.exec('PRAGMA writable_schema=OFF');}catch{}throw error;}
|
|
304
|
+
finally{if(relaxed)db.enableDefensive(true);}
|
|
305
|
+
}
|
|
306
|
+
return applied;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Create the ledger on an empty file: 0001-init.sql, user_version=1, meta, schema_migrations — one transaction. */
|
|
310
|
+
function initLedger(db,{file,now,sqliteVersion,journalMode,repoRoot=null,product=null,ledgerId=null}){
|
|
311
|
+
const empty=()=>userVersion(db)===0&&!db.prepare("SELECT 1 FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' LIMIT 1").get();
|
|
312
|
+
if(!empty())return false;
|
|
313
|
+
beginImmediate(db);
|
|
314
|
+
try{
|
|
315
|
+
if(!empty()){db.exec('ROLLBACK');return false;}
|
|
316
|
+
const at=now();
|
|
317
|
+
db.exec(INIT_SQL);
|
|
318
|
+
db.exec('PRAGMA user_version=1');
|
|
319
|
+
const dirId=path.basename(path.dirname(path.resolve(file)));
|
|
320
|
+
const id=ledgerId??(UUID.test(dirId)?dirId:crypto.randomUUID());
|
|
321
|
+
const seed=db.prepare('INSERT INTO meta(key,value) VALUES(?,?)');
|
|
322
|
+
const meta={ledger_id:id,schema:LEDGER_SCHEMA,created_at:String(at),journal_mode:journalMode.toLowerCase(),sqlite_version:sqliteVersion,
|
|
323
|
+
blob_root:path.resolve(process.env.STARCI_ARTIFACT_ROOT||path.join(os.homedir(),'.starci','artifacts')),runtime_rev:runtimeRev()};
|
|
324
|
+
if(repoRoot)meta.repo_root=path.resolve(repoRoot);
|
|
325
|
+
if(product)meta.product=product;
|
|
326
|
+
for(const [k,v] of Object.entries(meta))if(v!=null)seed.run(k,String(v));
|
|
327
|
+
db.prepare("INSERT INTO schema_migrations(version,name,runtime_rev,sql_sha256,started_at,finished_at,status) VALUES(1,'0001-init',?,?,?,?,'done')")
|
|
328
|
+
.run(meta.runtime_rev,INIT_SQL_SHA,at,now());
|
|
329
|
+
db.exec('COMMIT');
|
|
330
|
+
return true;
|
|
331
|
+
}catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}
|
|
332
|
+
}
|
|
333
|
+
let cachedRev;
|
|
334
|
+
function runtimeRev(){
|
|
335
|
+
if(cachedRev!==undefined)return cachedRev;
|
|
336
|
+
try{
|
|
337
|
+
const root=path.dirname(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([a-z]:)/i,'$1')));
|
|
338
|
+
const head=fs.readFileSync(path.join(root,'.git','HEAD'),'utf8').trim();
|
|
339
|
+
cachedRev=head.startsWith('ref:')?fs.readFileSync(path.join(root,'.git',head.slice(5).trim()),'utf8').trim():head;
|
|
340
|
+
}catch{cachedRev=null;}
|
|
341
|
+
return cachedRev;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The ledger's own identity, from its `meta` row. Never derived from a path (§5). */
|
|
345
|
+
export function ledgerIdOf(handle){
|
|
346
|
+
need(handle?.db,'ledgerIdOf needs a handle');
|
|
347
|
+
return handle.db.prepare("SELECT value FROM meta WHERE key='ledger_id'").get()?.value??null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
351
|
+
// Readers: read-only handles (readOnly, query_only=ON, busy_timeout), never the writer's connection
|
|
352
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
353
|
+
/** A runtime.sqlite opened read-only with the enforced busy_timeout — what every reader outside the writer uses. */
|
|
354
|
+
export function openLedgerReader(file,{busyTimeoutMs=LEDGER_BUSY_TIMEOUT_MS,verify=true,queryOnly=true}={}){
|
|
355
|
+
const {DatabaseSync}=require('node:sqlite');
|
|
356
|
+
const db=new DatabaseSync(file,{readOnly:true,timeout:busyTimeoutMs});
|
|
357
|
+
try{
|
|
358
|
+
// queryOnly:false only for a backup's VACUUM INTO (the file itself stays read-only).
|
|
359
|
+
applyPragmas(db,queryOnly?READ_PRAGMAS:{...READ_PRAGMAS,query_only:'OFF'});
|
|
360
|
+
if(verify)verifyLedger(db,{file,sqliteVersion:db.prepare('select sqlite_version() AS version').get().version});
|
|
361
|
+
}catch(error){try{db.close();}catch{}throw error;}
|
|
362
|
+
return db;
|
|
363
|
+
}
|
|
364
|
+
const rowOf=row=>row?{...row,payload:parseJson(row.payload_json)}:null;
|
|
365
|
+
const readAccessors=db=>({
|
|
366
|
+
eventsHead(workflowId){return eventsHead(db,workflowId);},
|
|
367
|
+
getJob(jobId){return rowOf(db.prepare('SELECT * FROM jobs WHERE job_id=?').get(jobId));},
|
|
368
|
+
listJobs({status=null,kind=null,workflowId=null}={}){let sql='SELECT * FROM jobs WHERE 1=1';const args=[];if(status){sql+=' AND status=?';args.push(status);}if(kind){sql+=' AND kind=?';args.push(kind);}if(workflowId){sql+=' AND workflow_id=?';args.push(workflowId);}sql+=' ORDER BY created_at,job_id';return db.prepare(sql).all(...args).map(rowOf);},
|
|
369
|
+
events({workflowId=null,since=null}={}){let sql='SELECT * FROM events WHERE 1=1';const args=[];if(workflowId){sql+=' AND workflow_id=?';args.push(workflowId);}if(since!==null){sql+=' AND seq>?';args.push(since);}sql+=' ORDER BY seq';return db.prepare(sql).all(...args).map(rowOf);},
|
|
370
|
+
getWorkflow(workflowId){return db.prepare('SELECT * FROM workflows WHERE workflow_id=?').get(workflowId)??null;},
|
|
371
|
+
getAttempt(attemptId){return db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId)??null;},
|
|
372
|
+
meta(){return metaOf(db);},
|
|
373
|
+
});
|
|
374
|
+
/** A ledger opened read-only: no init, no writes. What operator inspection, the UI and the connectors use. */
|
|
375
|
+
export function inspectLedger({file}={}){
|
|
376
|
+
need(typeof file==='string'&&fs.existsSync(file),'inspectLedger needs an existing file');
|
|
377
|
+
const db=openLedgerReader(file);
|
|
378
|
+
return {schema:LEDGER_SCHEMA,file,path:path.resolve(file),db,readOnly:true,ledgerId:ledgerIdOf({db}),
|
|
379
|
+
version:userVersion(db),
|
|
380
|
+
...readAccessors(db),
|
|
381
|
+
close(){db.close();}};
|
|
382
|
+
}
|
|
383
|
+
/** The digest of the workflow's newest event row, or null when it has none. */
|
|
384
|
+
export function eventsHead(db,workflowId){
|
|
385
|
+
return db.prepare('SELECT digest FROM events WHERE workflow_id=? ORDER BY seq DESC LIMIT 1').get(workflowId)?.digest??null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
389
|
+
// The writer: typed write functions. Each takes the db of an open write transaction (handle.transaction).
|
|
390
|
+
// Column values are camelCase keys mapped to the table's snake_case columns; `xJson` columns take any JSON value
|
|
391
|
+
// under `x` or `xJson`. Unknown keys throw, so a caller can never write a column the schema does not have.
|
|
392
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
393
|
+
const snake=key=>key.replace(/[A-Z]/g,c=>`_${c.toLowerCase()}`);
|
|
394
|
+
const columnCache=new WeakMap();
|
|
395
|
+
const columnsOf=(db,table)=>{
|
|
396
|
+
let byDb=columnCache.get(db);if(!byDb)columnCache.set(db,byDb=new Map());
|
|
397
|
+
if(!byDb.has(table)){const cols=db.prepare(`PRAGMA table_xinfo(${table})`).all().filter(c=>c.hidden!==2&&c.hidden!==3).map(c=>c.name);need(cols.length,`unknown table ${table}`);byDb.set(table,new Set(cols));}
|
|
398
|
+
return byDb.get(table);
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* The free-text and JSON columns the writer redacts itself (scripts/lib/redact.mjs), so no caller can skip it: a log
|
|
402
|
+
* message, a report, a check command, an incident detail... Identity columns (ids, shas, paths of record) are never
|
|
403
|
+
* touched. events.payload_json is redacted in appendEvent, before its digest is taken.
|
|
404
|
+
*/
|
|
405
|
+
export const REDACTED_COLUMNS=Object.freeze({
|
|
406
|
+
logs:['msg','data_json','refs_json'],reports:['report_json'],check_runs:['command','note','summary_json','attribution_json'],
|
|
407
|
+
incidents:['detail','last_progress'],inbox:['payload_json','disposition_json'],decision_items:['summary','payload_json','evidence_json'],
|
|
408
|
+
decisions:['rationale','result_json'],contracts:['markdown','context_json'],api_requests:['result_json'],op_attempts:['settle_json','why_json'],
|
|
409
|
+
conditions:['message'],goals:['markdown','amendment_json'],settle_tails:['last_error'],product_lands:['reason','checks_json'],
|
|
410
|
+
record_changes:['reason'],foundations:['detail'],foundation_declarations:['detail'],interface_audits:['findings_json'],
|
|
411
|
+
});
|
|
412
|
+
const redactValue=(col,val)=>{
|
|
413
|
+
if(val===null||val===undefined||typeof val!=='string')return val;
|
|
414
|
+
if(col.endsWith('_json')){try{return JSON.stringify(redactData(JSON.parse(val)));}catch{return redactText(val);}}
|
|
415
|
+
return redactText(val);
|
|
416
|
+
};
|
|
417
|
+
/** {camelKey:value} → [[column,sqlValue]] for `table`; JSON columns stringify; REDACTED_COLUMNS are redacted. */
|
|
418
|
+
function toColumns(db,table,fields){
|
|
419
|
+
const cols=columnsOf(db,table),out=[];
|
|
420
|
+
for(const [key,raw] of Object.entries(fields)){
|
|
421
|
+
if(raw===undefined)continue;
|
|
422
|
+
let col=snake(key),val=raw;
|
|
423
|
+
if(!cols.has(col)&&cols.has(`${col}_json`)){col=`${col}_json`;val=json(raw);}
|
|
424
|
+
else if(col.endsWith('_json')&&raw!==null&&typeof raw!=='string')val=json(raw);
|
|
425
|
+
need(cols.has(col),`${table} has no column ${col} (key ${key})`,'STARCI_LEDGER_UNKNOWN_COLUMN');
|
|
426
|
+
if(typeof val==='boolean')val=val?1:0;
|
|
427
|
+
if(REDACTED_COLUMNS[table]?.includes(col))val=redactValue(col,val);
|
|
428
|
+
out.push([col,val]);
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
function insertRow(db,table,fields,{orIgnore=false}={}){
|
|
433
|
+
const pairs=toColumns(db,table,fields);
|
|
434
|
+
const sql=`INSERT ${orIgnore?'OR IGNORE ':''}INTO ${table}(${pairs.map(p=>p[0]).join(',')}) VALUES(${pairs.map(()=>'?').join(',')})`;
|
|
435
|
+
return db.prepare(sql).run(...pairs.map(p=>p[1]));
|
|
436
|
+
}
|
|
437
|
+
function updateRow(db,table,where,fields){
|
|
438
|
+
const pairs=toColumns(db,table,fields),keys=toColumns(db,table,where);
|
|
439
|
+
if(!pairs.length)return {changes:0};
|
|
440
|
+
const sql=`UPDATE ${table} SET ${pairs.map(p=>`${p[0]}=?`).join(',')} WHERE ${keys.map(p=>`${p[0]} IS ?`).join(' AND ')}`;
|
|
441
|
+
return db.prepare(sql).run(...pairs.map(p=>p[1]),...keys.map(p=>p[1]));
|
|
442
|
+
}
|
|
443
|
+
const nowMs=()=>Date.now();
|
|
444
|
+
const EVENT_PAYLOAD_MAX=16384;
|
|
445
|
+
|
|
446
|
+
// --- blobs ------------------------------------------------------------------------------------------------
|
|
447
|
+
/** Index a blob already in the store (scripts/lib/artifact-store.mjs putBlob). Idempotent on sha256. */
|
|
448
|
+
export function recordBlob(db,{sha256:sha,bytes,mediaType,fileUri,encoding=null,redaction=null,pinned=0,createdAt=nowMs()}){
|
|
449
|
+
need(/^[a-f0-9]{64}$/.test(sha??''),'recordBlob needs a sha256');
|
|
450
|
+
need(Number.isInteger(bytes)&&bytes>=0&&mediaType&&fileUri,'recordBlob needs bytes, mediaType and fileUri');
|
|
451
|
+
insertRow(db,'blobs',{sha256:sha,bytes,mediaType,fileUri:String(fileUri).replace(/\\/g,'/'),encoding,redaction,pinned,createdAt},{orIgnore:true});
|
|
452
|
+
if(pinned)db.prepare('UPDATE blobs SET pinned=1 WHERE sha256=?').run(sha);
|
|
453
|
+
return db.prepare('SELECT * FROM blobs WHERE sha256=?').get(sha);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Put `content` (Buffer, Uint8Array or a file path) in the blob store and index it; returns the blobs row. `redaction`
|
|
457
|
+
* is the caller's claim ('v1' after scripts/lib/redact.mjs, 'binary', or null).
|
|
458
|
+
*/
|
|
459
|
+
export function storeBlob(db,{content,mediaType='application/octet-stream',redaction=null,createdAt=nowMs()}){
|
|
460
|
+
const {sha,size}=putBlob(content,{mediaType});
|
|
461
|
+
return recordBlob(db,{sha256:sha,bytes:size,mediaType,fileUri:blobPath(sha),redaction,createdAt});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --- events -----------------------------------------------------------------------------------------------
|
|
465
|
+
/** digest = sha256(prev_digest || event_id || kind || coalesce(payload_json,'') || created_at), per workflow chain. */
|
|
466
|
+
export const eventDigest=({prevDigest,eventId,kind,payloadJson,createdAt})=>sha256(`${prevDigest??''}${eventId}${kind}${payloadJson??''}${createdAt}`);
|
|
467
|
+
/**
|
|
468
|
+
* One events row. The chain link is computed here inside the caller's BEGIN IMMEDIATE. A payload over 16 KiB goes to
|
|
469
|
+
* the blob store (payloadSha) and the row keeps no payload_json.
|
|
470
|
+
*/
|
|
471
|
+
export function appendEvent(db,{eventId=newToken(),workflowId,entityType,entityId,generation=null,kind,payload=null,payloadSha=null,
|
|
472
|
+
attemptId=null,spanId=null,occurredAt=null,createdAt=nowMs()}){
|
|
473
|
+
need(workflowId&&entityType&&entityId!=null&&kind,'Event workflowId, entityType, entityId and kind are required');
|
|
474
|
+
let payloadJson=payload===null||payload===undefined?null:JSON.stringify(redactData(payload));
|
|
475
|
+
need(payloadJson===null||payloadJson.length<=EVENT_PAYLOAD_MAX||payloadSha,`event payload is ${payloadJson?.length} bytes (> ${EVENT_PAYLOAD_MAX}); store it as a blob and pass payloadSha`,'STARCI_EVENT_PAYLOAD_TOO_LARGE');
|
|
476
|
+
if(payloadSha&&payloadJson&&payloadJson.length>EVENT_PAYLOAD_MAX)payloadJson=null;
|
|
477
|
+
const gen=generation??db.prepare('SELECT generation FROM workflows WHERE workflow_id=?').get(workflowId)?.generation??0;
|
|
478
|
+
const prevDigest=eventsHead(db,workflowId);
|
|
479
|
+
const digest=eventDigest({prevDigest,eventId,kind,payloadJson,createdAt});
|
|
480
|
+
const {lastInsertRowid}=insertRow(db,'events',{eventId,workflowId,generation:gen,entityType,entityId:String(entityId),attemptId,spanId,kind,
|
|
481
|
+
payloadJson,payloadSha,prevDigest,digest,occurredAt:occurredAt??createdAt,createdAt});
|
|
482
|
+
return db.prepare('SELECT * FROM events WHERE seq=?').get(lastInsertRowid);
|
|
483
|
+
}
|
|
484
|
+
/** Recompute a workflow's chain; returns {ok, count, brokenAt}. */
|
|
485
|
+
export function verifyEventChain(db,workflowId){
|
|
486
|
+
let prev=null,count=0;
|
|
487
|
+
for(const row of db.prepare('SELECT * FROM events WHERE workflow_id=? ORDER BY seq').iterate(workflowId)){
|
|
488
|
+
count++;
|
|
489
|
+
if(row.prev_digest!==prev||row.digest!==eventDigest({prevDigest:prev,eventId:row.event_id,kind:row.kind,payloadJson:row.payload_json,createdAt:row.created_at}))
|
|
490
|
+
return {ok:false,count,brokenAt:row.seq};
|
|
491
|
+
prev=row.digest;
|
|
492
|
+
}
|
|
493
|
+
return {ok:true,count,brokenAt:null};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// --- workflows, lifecycle, goals ----------------------------------------------------------------------------
|
|
497
|
+
/** Create a workflow (and its first lifecycle_changes row + event). Throws when it exists. */
|
|
498
|
+
export function createWorkflow(db,{workflowId,phase='awaiting-approval',by='owner',reason='define-goal',traceId=newTraceId(),title=null,displayName=null,
|
|
499
|
+
ledgerMode=null,sourceRoots=null,goalIdentity=null,pinDigest=null,allowedParallel=null,at=nowMs()}){
|
|
500
|
+
need(workflowId,'createWorkflow needs a workflow id');
|
|
501
|
+
need(['awaiting-approval','queued'].includes(phase),`a workflow is created awaiting-approval or queued, not ${phase}`);
|
|
502
|
+
insertRow(db,'workflows',{workflowId,traceId,title,displayName,phase,phaseReason:reason,ledgerMode,sourceRoots,goalIdentity,pinDigest,allowedParallel,createdAt:at,updatedAt:at});
|
|
503
|
+
insertRow(db,'lifecycle_changes',{workflowId,fromPhase:null,toPhase:phase,by,reason,at});
|
|
504
|
+
appendEvent(db,{workflowId,entityType:'workflow',entityId:workflowId,kind:'workflow-created',payload:{phase,by,reason},createdAt:at});
|
|
505
|
+
return db.prepare('SELECT * FROM workflows WHERE workflow_id=?').get(workflowId);
|
|
506
|
+
}
|
|
507
|
+
/** Create the workflow when absent (phase `phase`), else return it; a set displayName is never overwritten. */
|
|
508
|
+
export function ensureWorkflow(db,{workflowId,phase='queued',by='kernel',reason='ensure',title=null,displayName=null,ledgerMode=null,sourceRoots=null,at=nowMs()}={}){
|
|
509
|
+
need(workflowId,'ensureWorkflow needs a workflow id');
|
|
510
|
+
const row=db.prepare('SELECT * FROM workflows WHERE workflow_id=?').get(workflowId);
|
|
511
|
+
if(!row)return createWorkflow(db,{workflowId,phase,by,reason,title,displayName,ledgerMode,sourceRoots,at});
|
|
512
|
+
if(displayName!==null&&row.display_name===null)db.prepare('UPDATE workflows SET display_name=?,updated_at=? WHERE workflow_id=?').run(displayName,at,workflowId);
|
|
513
|
+
return db.prepare('SELECT * FROM workflows WHERE workflow_id=?').get(workflowId);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Move workflows.phase along workflow_transitions: lifecycle_changes row, UPDATE, event — one transaction (the
|
|
517
|
+
* workflows_phase_guard trigger refuses an unrecorded or invalid move). No-op (returns false) when already at `to`.
|
|
518
|
+
*/
|
|
519
|
+
export function changeWorkflowPhase(db,{workflowId,to,by,reason,at=nowMs(),finished=undefined}){
|
|
520
|
+
need(workflowId&&to&&by&&reason,'changeWorkflowPhase needs workflowId, to, by and reason');
|
|
521
|
+
const row=db.prepare('SELECT phase FROM workflows WHERE workflow_id=?').get(workflowId);
|
|
522
|
+
need(row,`workflow ${workflowId} not found`,'STARCI_WORKFLOW_NOT_FOUND');
|
|
523
|
+
if(row.phase===to)return false;
|
|
524
|
+
insertRow(db,'lifecycle_changes',{workflowId,fromPhase:row.phase,toPhase:to,by,reason,at});
|
|
525
|
+
const fields={phase:to,phaseReason:reason,updatedAt:at};
|
|
526
|
+
if(to==='finished'){fields.finishedAt=at;if(finished!==undefined)fields.finishedJson=json(finished);}
|
|
527
|
+
if(to==='archived')fields.archivedAt=at;
|
|
528
|
+
// The event goes first: an archived workflow refuses every later write (events_refuse_archived), its own last event included.
|
|
529
|
+
appendEvent(db,{workflowId,entityType:'workflow',entityId:workflowId,kind:'phase-transition',payload:{from:row.phase,to,by,reason},createdAt:at});
|
|
530
|
+
updateRow(db,'workflows',{workflowId},fields);
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
const WORKFLOW_SPEC_FIELDS=new Set(['title','displayName','ledgerMode','sourceRoots','generation','observedGeneration','goalIdentity','pinDigest','allowedParallel','finished']);
|
|
534
|
+
/** Update non-phase workflow fields (phase moves only through changeWorkflowPhase). */
|
|
535
|
+
export function updateWorkflow(db,{workflowId,at=nowMs(),...fields}){
|
|
536
|
+
for(const key of Object.keys(fields))need(WORKFLOW_SPEC_FIELDS.has(key),`updateWorkflow cannot set ${key}`);
|
|
537
|
+
return updateRow(db,'workflows',{workflowId},{...fields,updatedAt:at}).changes>0;
|
|
538
|
+
}
|
|
539
|
+
/** The workflows.phase queued → running move (kernel boot, first dispatch). Returns true when this call moved it. */
|
|
540
|
+
export function transitionWorkflowToRunning(ledgerOrDb,{workflowId,now=Date.now(),by='kernel',reason='kernel-start'}){
|
|
541
|
+
const db=ledgerOrDb.db??ledgerOrDb;
|
|
542
|
+
const phase=db.prepare('SELECT phase FROM workflows WHERE workflow_id=?').get(workflowId)?.phase;
|
|
543
|
+
if(phase!=='queued'){db.prepare('UPDATE workflows SET updated_at=? WHERE workflow_id=?').run(now,workflowId);return false;}
|
|
544
|
+
return changeWorkflowPhase(db,{workflowId,to:'running',by,reason,at:now});
|
|
545
|
+
}
|
|
546
|
+
export function insertGoal(db,{workflowId,revision,goalIdentity,markdown,goal,amendment=null,approvedBy=null,approvalRef=null,createdAt=nowMs()}){
|
|
547
|
+
need(workflowId&&Number.isInteger(revision)&&goalIdentity&&typeof markdown==='string'&&goal!==undefined,'insertGoal needs workflowId, revision, goalIdentity, markdown and goal');
|
|
548
|
+
const {lastInsertRowid}=insertRow(db,'goals',{workflowId,revision,goalIdentity,markdown,json:json(goal),amendment,approvedBy,approvalRef,createdAt});
|
|
549
|
+
appendEvent(db,{workflowId,entityType:'workflow',entityId:workflowId,kind:'goal-revision',payload:{revision,goalIdentity},createdAt});
|
|
550
|
+
return Number(lastInsertRowid);
|
|
551
|
+
}
|
|
552
|
+
/** Rewrite a goal revision's derived json (api plan: the derived plan); markdown and approval never change. */
|
|
553
|
+
export function updateGoalJson(db,{goalSeq,goal,at=nowMs()}){
|
|
554
|
+
const row=db.prepare('SELECT workflow_id,revision FROM goals WHERE goal_seq=?').get(goalSeq);
|
|
555
|
+
need(row,`goal ${goalSeq} not found`);
|
|
556
|
+
updateRow(db,'goals',{goalSeq},{json:json(goal)});
|
|
557
|
+
appendEvent(db,{workflowId:row.workflow_id,entityType:'goal',entityId:row.workflow_id,kind:'goal-json-updated',payload:{revision:row.revision},createdAt:at});
|
|
558
|
+
}
|
|
559
|
+
/** Inbox rows by (workflow, kind, key[, status]) moved to `status`; returns how many moved. */
|
|
560
|
+
export function setInboxStatusByKey(db,{workflowId,kind,key=null,onlyStatus=null,status,disposition=undefined,at=nowMs()}){
|
|
561
|
+
let sql='SELECT inbox_id FROM inbox WHERE workflow_id=? AND kind=?';const args=[workflowId,kind];
|
|
562
|
+
if(key!==null){sql+=' AND key=?';args.push(key);}
|
|
563
|
+
if(onlyStatus){sql+=' AND status=?';args.push(onlyStatus);}
|
|
564
|
+
let n=0;for(const r of db.prepare(sql).all(...args))if(setInboxStatus(db,{inboxId:r.inbox_id,status,disposition,at}))n++;
|
|
565
|
+
return n;
|
|
566
|
+
}
|
|
567
|
+
export function recordGoalInput(db,{workflowId,key,goalRevision,sha256:sha,origin,createdAt=nowMs()}){
|
|
568
|
+
insertRow(db,'goal_inputs',{workflowId,key,goalRevision,sha256:sha,origin,createdAt});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// --- units and edges ----------------------------------------------------------------------------------------
|
|
572
|
+
/** A work unit (one logical step). UNIQUE(workflow, op, subject_key, goal_revision): the same work is never a new unit. */
|
|
573
|
+
export function createUnit(db,{workflowId,unitId,opId,subjectKey,goalRevision,title=null,cutId=null,cutOrdinal=null,cutTotal=null,repository=null,
|
|
574
|
+
state='planned',tryBudget=DEFAULT_TRY_BUDGET,createdAt=nowMs()}){
|
|
575
|
+
need(workflowId&&unitId&&opId&&subjectKey&&Number.isInteger(goalRevision),'createUnit needs workflowId, unitId, opId, subjectKey and goalRevision');
|
|
576
|
+
insertRow(db,'work_units',{workflowId,unitId,opId,subjectKey,goalRevision,title,cutId,cutOrdinal,cutTotal,repository,state,tryBudget,createdAt,updatedAt:createdAt});
|
|
577
|
+
appendEvent(db,{workflowId,entityType:'unit',entityId:unitId,kind:'unit-created',payload:{opId,subjectKey,goalRevision,state},createdAt});
|
|
578
|
+
return getUnit(db,workflowId,unitId);
|
|
579
|
+
}
|
|
580
|
+
export const getUnit=(db,workflowId,unitId)=>db.prepare('SELECT * FROM work_units WHERE workflow_id=? AND unit_id=?').get(workflowId,unitId)??null;
|
|
581
|
+
/** Move a unit's state (+ event). A done unit leaves 'done' only through reopenUnit. */
|
|
582
|
+
export function setUnitState(db,{workflowId,unitId,to,reason=null,at=nowMs(),currentJobId=undefined}){
|
|
583
|
+
const unit=getUnit(db,workflowId,unitId);
|
|
584
|
+
need(unit,`unit ${workflowId}/${unitId} not found`,'STARCI_UNIT_NOT_FOUND');
|
|
585
|
+
const fields={state:to,updatedAt:at};
|
|
586
|
+
if(currentJobId!==undefined)fields.currentJobId=currentJobId;
|
|
587
|
+
if(to==='done')fields.doneAt=at;
|
|
588
|
+
if(unit.state===to&¤tJobId===undefined)return false;
|
|
589
|
+
updateRow(db,'work_units',{workflowId,unitId},fields);
|
|
590
|
+
if(unit.state!==to)appendEvent(db,{workflowId,entityType:'unit',entityId:unitId,kind:'unit-state',payload:{from:unit.state,to,reason},createdAt:at});
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
/** H5: the only way to rerun a done unit — a reason, who, and a new reopened_at. */
|
|
594
|
+
export function reopenUnit(db,{workflowId,unitId,reason,by,to='queued',at=nowMs()}){
|
|
595
|
+
need(reason&&by,'reopenUnit needs a reason and who reopens');
|
|
596
|
+
const unit=getUnit(db,workflowId,unitId);need(unit,`unit ${workflowId}/${unitId} not found`);
|
|
597
|
+
updateRow(db,'work_units',{workflowId,unitId},{state:to,reopenReason:reason,reopenedBy:by,reopenedAt:at,doneAt:null,updatedAt:at});
|
|
598
|
+
appendEvent(db,{workflowId,entityType:'unit',entityId:unitId,kind:'unit-reopened',payload:{from:unit.state,to,reason,by},createdAt:at});
|
|
599
|
+
}
|
|
600
|
+
/** Q13: raise a unit's try_budget above 5 — only the owner or the Supervisor, with a reference. */
|
|
601
|
+
export function raiseTryBudget(db,{workflowId,unitId,tryBudget,by,ref,at=nowMs()}){
|
|
602
|
+
need(['owner','supervisor'].includes(by)&&ref&&Number.isInteger(tryBudget),'raiseTryBudget needs by (owner|supervisor), ref and an integer budget');
|
|
603
|
+
updateRow(db,'work_units',{workflowId,unitId},{tryBudget,budgetRaisedBy:by,budgetRaisedRef:ref,updatedAt:at});
|
|
604
|
+
appendEvent(db,{workflowId,entityType:'unit',entityId:unitId,kind:'unit-budget-raised',payload:{tryBudget,by,ref},createdAt:at});
|
|
605
|
+
}
|
|
606
|
+
export function addUnitEdge(db,{workflowId,fromUnit,toUnit,kind,source,createdAt=nowMs()}){
|
|
607
|
+
return insertRow(db,'unit_edges',{workflowId,fromUnit,toUnit,kind,source,createdAt},{orIgnore:true}).changes>0;
|
|
608
|
+
}
|
|
609
|
+
/** One work_graph_versions row + its event (`eventKind`/`eventPayload` name the caller's event; default graph-<event>). */
|
|
610
|
+
export function recordGraphVersion(db,{workflowId,version,event,graph,diff,colors,reason,authorOp,authorJob=null,digest,createdAt=nowMs(),
|
|
611
|
+
eventKind=`graph-${event}`,eventPayload={version,event,reason,authorOp,authorJob,digest}}){
|
|
612
|
+
insertRow(db,'work_graph_versions',{workflowId,version,event,graphJson:json(graph),diffJson:json(diff),colorsJson:json(colors),reason,authorOp,authorJob,digest,createdAt});
|
|
613
|
+
appendEvent(db,{workflowId,entityType:'work-graph',entityId:workflowId,kind:eventKind,payload:eventPayload,createdAt});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// --- jobs -------------------------------------------------------------------------------------------------
|
|
617
|
+
/**
|
|
618
|
+
* Enqueue one job (a try of a unit). The jobs_enqueue_guard trigger enforces unit, budget and lineage (H3/H4/H5/H9);
|
|
619
|
+
* this also bumps work_units.tries/current_job_id and moves a planned unit to queued. Idempotent on jobId.
|
|
620
|
+
*/
|
|
621
|
+
export function enqueueJob(db,{jobId,workflowId,unitId=null,opId=null,tryNo=1,retryOf=null,resumeOf=null,retryClass=null,generation=null,kind='op',role=null,
|
|
622
|
+
status='queued',payload=null,priority=null,deadline=null,createdAt=nowMs()}){
|
|
623
|
+
need(jobId&&workflowId&&kind,'Job identity and kind are required');
|
|
624
|
+
const existing=db.prepare('SELECT * FROM jobs WHERE job_id=?').get(jobId);
|
|
625
|
+
if(existing)return rowOf(existing);
|
|
626
|
+
const gen=generation??db.prepare('SELECT generation FROM workflows WHERE workflow_id=?').get(workflowId)?.generation??0;
|
|
627
|
+
insertRow(db,'jobs',{jobId,workflowId,unitId,opId,tryNo,retryOf,resumeOf,retryClass,generation:gen,kind,role,status,payload,priority,deadline,createdAt,updatedAt:createdAt});
|
|
628
|
+
if(unitId){
|
|
629
|
+
db.prepare('UPDATE work_units SET tries=max(tries,?),current_job_id=?,updated_at=? WHERE workflow_id=? AND unit_id=?').run(tryNo,jobId,createdAt,workflowId,unitId);
|
|
630
|
+
const unit=getUnit(db,workflowId,unitId);
|
|
631
|
+
if(unit&&['planned','failed'].includes(unit.state))setUnitState(db,{workflowId,unitId,to:'queued',reason:`job ${jobId}`,at:createdAt});
|
|
632
|
+
}
|
|
633
|
+
appendEvent(db,{workflowId,entityType:'job',entityId:jobId,kind:'job-enqueued',payload:{opId,unitId,tryNo,retryOf,resumeOf,retryClass,status},createdAt});
|
|
634
|
+
return rowOf(db.prepare('SELECT * FROM jobs WHERE job_id=?').get(jobId));
|
|
635
|
+
}
|
|
636
|
+
const JOB_MUTABLE=new Set(['leaseToken','workerId','deadline','payload','priority','role']);
|
|
637
|
+
/**
|
|
638
|
+
* Move jobs.status along job_transitions (+ one event). `fields` may set lease_token, worker_id, deadline, payload,
|
|
639
|
+
* priority. A terminal status drops the job's leases (trigger). Returns false when the job already is at `to`.
|
|
640
|
+
*/
|
|
641
|
+
export function setJobStatus(db,{jobId,to,reason=null,at=nowMs(),spanId=null,attemptId=null,expect=null,...fields}){
|
|
642
|
+
for(const key of Object.keys(fields))need(JOB_MUTABLE.has(key),`setJobStatus cannot set ${key}`);
|
|
643
|
+
const job=db.prepare('SELECT * FROM jobs WHERE job_id=?').get(jobId);
|
|
644
|
+
need(job,`job ${jobId} not found`,'STARCI_JOB_NOT_FOUND');
|
|
645
|
+
if(expect)need([expect].flat().includes(job.status),`job ${jobId} is ${job.status}, expected ${[expect].flat().join('|')}`,'STARCI_JOB_STATUS_CONFLICT');
|
|
646
|
+
if(job.status===to){if(Object.keys(fields).length)updateRow(db,'jobs',{jobId},{...fields,updatedAt:at});return false;}
|
|
647
|
+
updateRow(db,'jobs',{jobId},{...fields,status:to,updatedAt:at});
|
|
648
|
+
appendEvent(db,{workflowId:job.workflow_id,entityType:'job',entityId:jobId,kind:'job-status',attemptId,spanId,payload:{from:job.status,to,reason},createdAt:at});
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* A job's result (jobs has no result column: DBTREE keeps results on the attempt). With an attempt: the job's newest
|
|
653
|
+
* attempt gets settle_json = result (and verdict / failure_class / next_step when the result names valid ones). Without
|
|
654
|
+
* one (a job cancelled before dispatch, a kernel job): one 'job-result' event carries it. Read back with jobResult.
|
|
655
|
+
*/
|
|
656
|
+
const VERDICTS=new Set(['pass','fail','partial','blocked','dropped','cancelled']);
|
|
657
|
+
export function recordJobResult(db,{jobId,result,at=nowMs()}){
|
|
658
|
+
const job=db.prepare('SELECT * FROM jobs WHERE job_id=?').get(jobId);
|
|
659
|
+
need(job,`job ${jobId} not found`,'STARCI_JOB_NOT_FOUND');
|
|
660
|
+
const attempt=db.prepare('SELECT attempt_id FROM op_attempts WHERE job_id=? ORDER BY attempt_id DESC LIMIT 1').get(jobId);
|
|
661
|
+
if(attempt){
|
|
662
|
+
const fields={settleJson:json(result??null)};
|
|
663
|
+
if(result&&VERDICTS.has(result.verdict))fields.verdict=result.verdict;
|
|
664
|
+
if(result?.failureClass!=null)fields.failureClass=typeof result.failureClass==='string'?result.failureClass:json(result.failureClass);
|
|
665
|
+
if(result?.nextStep!=null)fields.nextStep=typeof result.nextStep==='string'?result.nextStep:json(result.nextStep);
|
|
666
|
+
updateAttempt(db,{attemptId:attempt.attempt_id,at,...fields});
|
|
667
|
+
}else appendEvent(db,{workflowId:job.workflow_id,entityType:'job',entityId:jobId,kind:'job-result',payload:result??null,createdAt:at});
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
/** The result recordJobResult stored for `jobId` (the newest attempt's settle_json, else the newest job-result event), or null. */
|
|
671
|
+
export function jobResult(db,jobId){
|
|
672
|
+
const a=db.prepare('SELECT settle_json FROM op_attempts WHERE job_id=? ORDER BY attempt_id DESC LIMIT 1').get(jobId);
|
|
673
|
+
if(a)return parseJson(a.settle_json);
|
|
674
|
+
const e=db.prepare("SELECT payload_json FROM events WHERE entity_type='job' AND entity_id=? AND kind='job-result' ORDER BY seq DESC LIMIT 1").get(jobId);
|
|
675
|
+
return e?parseJson(e.payload_json):null;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The kernel seat's job (kernel-<wf>, kind 'kernel', no unit): created running on first boot; a released seat (ready)
|
|
679
|
+
* goes ready → leased → running; a running one is re-bound (adoption). A kernel job that ended cannot come back.
|
|
680
|
+
*/
|
|
681
|
+
export function bindKernelJob(db,{workflowId,workerId,payload=undefined,generation=undefined,reason='kernel-boot',at=nowMs()}){
|
|
682
|
+
const jobId=`kernel-${workflowId}`;
|
|
683
|
+
const job=db.prepare('SELECT * FROM jobs WHERE job_id=?').get(jobId);
|
|
684
|
+
if(!job){enqueueJob(db,{jobId,workflowId,kind:'kernel',role:'kernel',status:'running',payload:payload??null,generation,createdAt:at});updateRow(db,'jobs',{jobId},{workerId,updatedAt:at});return jobId;}
|
|
685
|
+
need(!SETTLED_JOB_STATUSES.includes(job.status),`kernel-job-ended: ${jobId} is ${job.status}`,'STARCI_KERNEL_JOB_ENDED');
|
|
686
|
+
const fields={workerId};if(payload!==undefined)fields.payload=payload;if(generation!==undefined)fields.generation=generation;
|
|
687
|
+
if(job.status==='running'){updateRow(db,'jobs',{jobId},{...fields,updatedAt:at});return jobId;}
|
|
688
|
+
if(job.status==='queued')setJobStatus(db,{jobId,to:'ready',reason,at});
|
|
689
|
+
if(['queued','ready'].includes(job.status))setJobStatus(db,{jobId,to:'leased',reason,at});
|
|
690
|
+
updateRow(db,'jobs',{jobId},{...fields,updatedAt:at});
|
|
691
|
+
setJobStatus(db,{jobId,to:'running',reason,at});
|
|
692
|
+
return jobId;
|
|
693
|
+
}
|
|
694
|
+
/** Release the kernel seat's job (running → ready) so a later boot re-binds it; returns false when it was not running. */
|
|
695
|
+
export function releaseKernelJob(db,{workflowId,reason='kernel-stopped',at=nowMs()}){
|
|
696
|
+
const jobId=`kernel-${workflowId}`;
|
|
697
|
+
const job=db.prepare('SELECT status FROM jobs WHERE job_id=?').get(jobId);
|
|
698
|
+
if(job?.status!=='running')return false;
|
|
699
|
+
setJobStatus(db,{jobId,to:'ready',reason,workerId:null,at});
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
/** Update non-status job fields (lease token, worker, deadline, payload, priority). */
|
|
703
|
+
export function updateJob(db,{jobId,at=nowMs(),...fields}){
|
|
704
|
+
for(const key of Object.keys(fields))need(JOB_MUTABLE.has(key),`updateJob cannot set ${key}`);
|
|
705
|
+
return updateRow(db,'jobs',{jobId},{...fields,updatedAt:at}).changes>0;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// --- attempts, contracts ---------------------------------------------------------------------------------------
|
|
709
|
+
/**
|
|
710
|
+
* One op_attempts row per dispatch. The dispatch guard requires the job leased and the workflow running. spanId is
|
|
711
|
+
* minted when absent; the attempt's dispatch_seq follows the job's previous dispatches.
|
|
712
|
+
*/
|
|
713
|
+
export function startAttempt(db,{workflowId,jobId,dispatchId,spanId=newSpanId(),parentSpanId=null,at=nowMs(),...fields}){
|
|
714
|
+
const job=db.prepare('SELECT * FROM jobs WHERE job_id=?').get(jobId);
|
|
715
|
+
need(job,`job ${jobId} not found`,'STARCI_JOB_NOT_FOUND');
|
|
716
|
+
need(dispatchId,'startAttempt needs a dispatch id');
|
|
717
|
+
const dispatchSeq=Number(db.prepare('SELECT COALESCE(max(dispatch_seq),0)+1 n FROM op_attempts WHERE job_id=?').get(jobId).n);
|
|
718
|
+
const {lastInsertRowid}=insertRow(db,'op_attempts',{workflowId:workflowId??job.workflow_id,jobId,unitId:job.unit_id,opId:job.op_id,tryNo:job.try_no,dispatchSeq,dispatchId,
|
|
719
|
+
spanId,parentSpanId,routedAt:fields.routedAt??at,...fields});
|
|
720
|
+
const attemptId=Number(lastInsertRowid);
|
|
721
|
+
if(job.unit_id)db.prepare('UPDATE work_units SET dispatches=dispatches+1,updated_at=? WHERE workflow_id=? AND unit_id=?').run(at,job.workflow_id,job.unit_id);
|
|
722
|
+
appendEvent(db,{workflowId:job.workflow_id,entityType:'attempt',entityId:String(attemptId),attemptId,spanId,kind:'attempt-started',payload:{jobId,dispatchId,dispatchSeq},createdAt:at});
|
|
723
|
+
return db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
724
|
+
}
|
|
725
|
+
const ATTEMPT_IDENTITY=new Set(['attempt_id','workflow_id','job_id','unit_id','op_id','try_no','dispatch_seq','dispatch_id','span_id']);
|
|
726
|
+
/** Update an attempt's facts; any *_at, verdict or end_state change appends one 'attempt-updated' event naming the changed columns. */
|
|
727
|
+
export function updateAttempt(db,{attemptId,at=nowMs(),...fields}){
|
|
728
|
+
const row=db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
729
|
+
need(row,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
730
|
+
const pairs=toColumns(db,'op_attempts',fields);
|
|
731
|
+
for(const [col] of pairs)need(!ATTEMPT_IDENTITY.has(col),`updateAttempt cannot change ${col}`);
|
|
732
|
+
const changed=pairs.filter(([col,val])=>row[col]!==val);
|
|
733
|
+
if(!changed.length)return false;
|
|
734
|
+
updateRow(db,'op_attempts',{attemptId},Object.fromEntries(changed.map(([c,v])=>[c.replace(/_([a-z])/g,(_,x)=>x.toUpperCase()),v])));
|
|
735
|
+
const marks=changed.filter(([col])=>/_at$|^verdict$|^end_state$|^report_outcome$/.test(col));
|
|
736
|
+
if(marks.length)appendEvent(db,{workflowId:row.workflow_id,entityType:'attempt',entityId:String(attemptId),attemptId,spanId:row.span_id,kind:'attempt-updated',
|
|
737
|
+
payload:Object.fromEntries(marks),createdAt:at});
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* A launch the host refused ends its attempt here, in the refusal's own transaction: end_state requeued (nothing ran, the
|
|
742
|
+
* job goes back to ready) or effect-unknown (a worker may have started; reconcile decides). A requeued attempt is also
|
|
743
|
+
* settled by the kernel (settled_at, no verdict: nothing was judged) and released, so every reader that asks "still open?"
|
|
744
|
+
* answers no; task_closed_at is stamped when its Orca Task was closed. A refused launch is never a try of the unit and
|
|
745
|
+
* never a dispatch it spent: work_units.dispatches (the "attempts" the UI counts) gives the slot back. The legacy shape
|
|
746
|
+
* (end_state requeued written by the refusal, settled_at and released_at never stamped) is sealed the same way, once.
|
|
747
|
+
* An attempt that is already sealed or ended otherwise is left alone (returns false).
|
|
748
|
+
*/
|
|
749
|
+
export function endRejectedAttempt(db,{attemptId,endState,effectState,releasedAt=null,taskClosedAt=null,at=nowMs()}){
|
|
750
|
+
need(['requeued','effect-unknown'].includes(endState),`a refused launch ends requeued or effect-unknown, not ${endState}`);
|
|
751
|
+
const row=db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
752
|
+
need(row,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
753
|
+
if(row.settled_at!=null||!(row.end_state==null||row.end_state==='requeued'))return false;
|
|
754
|
+
updateAttempt(db,{attemptId,at,endState,effectState,...(releasedAt!=null?{releasedAt,settledAt:releasedAt,settledBy:'kernel'}:{}),...(taskClosedAt!=null?{taskClosedAt}:{})});
|
|
755
|
+
if(row.unit_id)db.prepare('UPDATE work_units SET dispatches=max(dispatches-1,0),updated_at=? WHERE workflow_id=? AND unit_id=?').run(at,row.workflow_id,row.unit_id);
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
/** Re-baseline an attempt's contract context (settle's input digests); the markdown and revision never change. */
|
|
759
|
+
export function updateContractContext(db,{attemptId,context}){
|
|
760
|
+
need(db.prepare('SELECT 1 FROM contracts WHERE attempt_id=?').get(attemptId),`contract of attempt ${attemptId} not found`);
|
|
761
|
+
updateRow(db,'contracts',{attemptId},{context});
|
|
762
|
+
}
|
|
763
|
+
export function writeContract(db,{attemptId,markdown,context=null,contractRev=null,createdAt=nowMs()}){
|
|
764
|
+
const a=db.prepare('SELECT workflow_id,job_id FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
765
|
+
need(a,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
766
|
+
insertRow(db,'contracts',{attemptId,workflowId:a.workflow_id,jobId:a.job_id,contractRev,markdown,context,createdAt});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// --- leases and resources --------------------------------------------------------------------------------------
|
|
770
|
+
export function declareResource(db,{resourceKey,capacity,declaredBy=null,at=nowMs()}){
|
|
771
|
+
db.prepare('INSERT INTO resources(resource_key,capacity,declared_by,declared_at) VALUES(?,?,?,?) ON CONFLICT(resource_key) DO UPDATE SET capacity=excluded.capacity,declared_by=excluded.declared_by,declared_at=excluded.declared_at').run(resourceKey,capacity,declaredBy,at);
|
|
772
|
+
}
|
|
773
|
+
/** One lease row, identity-checked against the job (leases_match_job trigger). */
|
|
774
|
+
export function acquireLease(db,{resourceKey,jobId,units=1,expiresAt,attemptId=null,holder=null,at=nowMs()}){
|
|
775
|
+
const job=db.prepare('SELECT * FROM jobs WHERE job_id=?').get(jobId);
|
|
776
|
+
need(job?.lease_token,`job ${jobId} holds no lease token`);
|
|
777
|
+
insertRow(db,'leases',{resourceKey,jobId,workflowId:job.workflow_id,opId:job.op_id,tryNo:job.try_no,generation:job.generation,token:job.lease_token,units,attemptId,holder,acquiredAt:at,expiresAt});
|
|
778
|
+
}
|
|
779
|
+
export function renewLeases(db,{jobId,expiresAt,attemptId=undefined,holder=undefined,at=nowMs()}){
|
|
780
|
+
const fields={renewedAt:at,expiresAt};if(attemptId!==undefined)fields.attemptId=attemptId;if(holder!==undefined)fields.holder=holder;
|
|
781
|
+
return updateRow(db,'leases',{jobId},fields).changes;
|
|
782
|
+
}
|
|
783
|
+
export function releaseLeases(db,{jobId}){return db.prepare('DELETE FROM leases WHERE job_id=?').run(jobId).changes;}
|
|
784
|
+
|
|
785
|
+
// --- api_requests (idempotency) ----------------------------------------------------------------------------------
|
|
786
|
+
/** request_id = --request-id, or sha(verb + dispatch_id + args). */
|
|
787
|
+
export const requestIdOf=({verb,dispatchId=null,args=null})=>sha256(`${verb}\n${dispatchId??''}\n${json(args)??''}`);
|
|
788
|
+
/**
|
|
789
|
+
* Run `fn(db)` once per request id inside the caller's transaction: a replay returns {replayed:true,result} from the
|
|
790
|
+
* stored row; the same id with other args throws. `fn`'s return value is the stored result.
|
|
791
|
+
*/
|
|
792
|
+
export function idempotent(db,{requestId=null,verb,caller=null,workflowId=null,attemptId=null,dispatchId=null,args=null,at=nowMs()},fn){
|
|
793
|
+
need(verb,'idempotent needs a verb');
|
|
794
|
+
const id=requestId??requestIdOf({verb,dispatchId,args});
|
|
795
|
+
const argsSha=sha256(json(args)??'');
|
|
796
|
+
const prior=db.prepare('SELECT * FROM api_requests WHERE request_id=?').get(id);
|
|
797
|
+
if(prior){
|
|
798
|
+
need(prior.verb===verb&&prior.args_sha===argsSha,`request-id-reused: ${id} was ${prior.verb} with other arguments`,'STARCI_REQUEST_ID_REUSED');
|
|
799
|
+
if(prior.status==='done')return {replayed:true,requestId:id,result:parseJson(prior.result_json)};
|
|
800
|
+
db.prepare('DELETE FROM api_requests WHERE request_id=?').run(id);
|
|
801
|
+
}
|
|
802
|
+
insertRow(db,'api_requests',{requestId:id,verb,caller,workflowId,attemptId,argsSha,status:'running',createdAt:at});
|
|
803
|
+
const result=fn(db);
|
|
804
|
+
updateRow(db,'api_requests',{requestId:id},{status:'done',resultJson:json(result??null),finishedAt:nowMs()});
|
|
805
|
+
return {replayed:false,requestId:id,result};
|
|
806
|
+
}
|
|
807
|
+
/** Record a failed request outside the rolled-back transaction (a replay reruns it). */
|
|
808
|
+
export function recordFailedRequest(db,{requestId,verb,caller=null,workflowId=null,attemptId=null,args=null,error,at=nowMs()}){
|
|
809
|
+
db.prepare("INSERT INTO api_requests(request_id,verb,caller,workflow_id,attempt_id,args_sha,status,result_json,created_at,finished_at) VALUES(?,?,?,?,?,?,'failed',?,?,?) ON CONFLICT(request_id) DO UPDATE SET status='failed',result_json=excluded.result_json,finished_at=excluded.finished_at")
|
|
810
|
+
.run(requestId,verb,caller,workflowId,attemptId,sha256(json(args)??''),json({error:String(error?.message??error)}),at,nowMs());
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// --- reports, checks, artifacts, transcripts, usage ----------------------------------------------------------------
|
|
814
|
+
/**
|
|
815
|
+
* File an attempt's report (H10: immutable). The same report again returns the stored row; a different one throws.
|
|
816
|
+
* Sets op_attempts.report_outcome/reported_at (+ event).
|
|
817
|
+
*/
|
|
818
|
+
export function fileReport(db,{attemptId,outcome,report,fromTerminal=null,createdAt=nowMs()}){
|
|
819
|
+
const a=db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
820
|
+
need(a,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
821
|
+
const reportJson=redactValue('report_json',typeof report==='string'?report:json(report));
|
|
822
|
+
const prior=db.prepare('SELECT * FROM reports WHERE attempt_id=?').get(attemptId);
|
|
823
|
+
if(prior){need(prior.report_json===reportJson&&prior.outcome===outcome,`report-already-filed: attempt ${attemptId} has a different report`,'STARCI_REPORT_ALREADY_FILED');return {...prior,replayed:true};}
|
|
824
|
+
const {lastInsertRowid}=insertRow(db,'reports',{workflowId:a.workflow_id,attemptId,dispatchId:a.dispatch_id,jobId:a.job_id,outcome,reportJson,fromTerminal,createdAt});
|
|
825
|
+
updateAttempt(db,{attemptId,reportOutcome:outcome,reportedAt:createdAt,at:createdAt});
|
|
826
|
+
return db.prepare('SELECT * FROM reports WHERE report_id=?').get(lastInsertRowid);
|
|
827
|
+
}
|
|
828
|
+
/** Mark an attempt's report consumed (by attemptId, or workflowId + dispatchId); returns true when it was not yet. */
|
|
829
|
+
export function markReportConsumed(db,{attemptId=null,workflowId=null,dispatchId=null,at=nowMs()}){
|
|
830
|
+
const id=attemptId??db.prepare('SELECT attempt_id FROM reports WHERE workflow_id=? AND dispatch_id=?').get(workflowId,dispatchId)?.attempt_id;
|
|
831
|
+
if(id==null)return false;
|
|
832
|
+
const changed=db.prepare('UPDATE reports SET consumed_at=? WHERE attempt_id=? AND consumed_at IS NULL').run(at,id).changes>0;
|
|
833
|
+
if(changed)updateAttempt(db,{attemptId:id,consumedAt:at,at});
|
|
834
|
+
return changed;
|
|
835
|
+
}
|
|
836
|
+
/** One check_runs row; run_seq follows earlier runs of the same (attempt, runner, phase, name). */
|
|
837
|
+
export function recordCheckRun(db,{attemptId,name,phase,runner,authority=runner==='op'?'declared':'runtime',status,spanId=newSpanId(),createdAt=nowMs(),...fields}){
|
|
838
|
+
const a=db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
839
|
+
need(a,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
840
|
+
const runSeq=fields.runSeq??Number(db.prepare('SELECT COALESCE(max(run_seq),0)+1 n FROM check_runs WHERE attempt_id=? AND runner=? AND phase=? AND name=?').get(attemptId,runner,phase,name).n);
|
|
841
|
+
const {lastInsertRowid}=insertRow(db,'check_runs',{workflowId:a.workflow_id,attemptId,jobId:a.job_id,opId:a.op_id,spanId,parentSpanId:a.span_id,name,phase,runner,authority,status,createdAt,...fields,runSeq});
|
|
842
|
+
return db.prepare('SELECT * FROM check_runs WHERE check_id=?').get(lastInsertRowid);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* One job_artifacts row over an indexed blob (H10: immutable; the same (attempt, name, sha) again returns the row, a
|
|
846
|
+
* different sha under the same name throws). A kernel artifact has no attempt (origin 'kernel').
|
|
847
|
+
*/
|
|
848
|
+
export function recordArtifact(db,{workflowId=null,attemptId=null,name,sha256:sha,role,kind,origin=attemptId?'op':'kernel',createdAt=nowMs(),...fields}){
|
|
849
|
+
const blob=db.prepare('SELECT * FROM blobs WHERE sha256=?').get(sha);
|
|
850
|
+
need(blob,`blob ${sha} is not indexed; recordBlob/storeBlob first`,'STARCI_BLOB_NOT_INDEXED');
|
|
851
|
+
const a=attemptId?db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId):null;
|
|
852
|
+
need(!attemptId||a,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
853
|
+
const wf=workflowId??a?.workflow_id;need(wf,'recordArtifact needs a workflow');
|
|
854
|
+
const prior=attemptId?db.prepare('SELECT * FROM job_artifacts WHERE attempt_id=? AND name=?').get(attemptId,name)
|
|
855
|
+
:db.prepare('SELECT * FROM job_artifacts WHERE workflow_id=? AND attempt_id IS NULL AND name=?').get(wf,name);
|
|
856
|
+
if(prior){need(prior.sha256===sha,`artifacts are immutable: ${name} already holds ${prior.sha256}`,'STARCI_ARTIFACT_IMMUTABLE');return prior;}
|
|
857
|
+
const {lastInsertRowid}=insertRow(db,'job_artifacts',{workflowId:wf,attemptId,jobId:a?.job_id??fields.jobId,opId:a?.op_id??fields.opId,name,sha256:sha,
|
|
858
|
+
bytes:blob.bytes,mediaType:blob.media_type,role,kind,origin,createdAt,...fields,...(a?{jobId:a.job_id,opId:a.op_id}:{})});
|
|
859
|
+
return db.prepare('SELECT * FROM job_artifacts WHERE artifact_id=?').get(lastInsertRowid);
|
|
860
|
+
}
|
|
861
|
+
export function attachToReport(db,{reportId,artifactId}){insertRow(db,'report_attachments',{reportId,artifactId},{orIgnore:true});}
|
|
862
|
+
export function recordArtifactProof(db,{artifactId,claims,codeSha=null,deps,createdAt=nowMs()}){
|
|
863
|
+
insertRow(db,'artifact_proofs',{artifactId,claimsJson:json(claims),codeSha,depsJson:json(deps),createdAt});
|
|
864
|
+
}
|
|
865
|
+
export function citeBlob(db,{recordId,recordPath,field,sha256:sha,artifactId=null,role=null,recordRev=null,createdAt=nowMs()}){
|
|
866
|
+
db.prepare('INSERT INTO work_citations(record_id,record_path,field,artifact_id,sha256,role,record_rev,created_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(record_id,field) DO UPDATE SET record_path=excluded.record_path,artifact_id=excluded.artifact_id,sha256=excluded.sha256,role=excluded.role,record_rev=excluded.record_rev,created_at=excluded.created_at')
|
|
867
|
+
.run(recordId,recordPath,field,artifactId,sha,role,recordRev,createdAt);
|
|
868
|
+
db.prepare('UPDATE blobs SET pinned=1 WHERE sha256=?').run(sha);
|
|
869
|
+
}
|
|
870
|
+
/** A periodic scrollback snapshot (UI-API §2.10); an unchanged scrollback adds nothing. */
|
|
871
|
+
export function recordTranscriptSnapshot(db,{attemptId,sha256:sha,lines,bytes,at=nowMs()}){
|
|
872
|
+
const a=db.prepare('SELECT workflow_id FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
873
|
+
need(a,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
874
|
+
return insertRow(db,'attempt_transcript_snapshots',{workflowId:a.workflow_id,attemptId,at,lines,bytes,sha256:sha},{orIgnore:true}).changes>0;
|
|
875
|
+
}
|
|
876
|
+
/** The final transcript / session / prompt blobs of an attempt. */
|
|
877
|
+
export function setAttemptTranscript(db,{attemptId,transcriptSha=undefined,sessionSha=undefined,promptSha=undefined,at=nowMs()}){
|
|
878
|
+
return updateAttempt(db,{attemptId,transcriptSha,sessionSha,promptSha,at});
|
|
879
|
+
}
|
|
880
|
+
/** Q11: token counts only; cost_usd stays NULL unless a provider reported it. */
|
|
881
|
+
export function recordLlmUsage(db,{workflowId,subjectType,attemptId=null,turnRef=null,provider,source,at=nowMs(),...fields}){
|
|
882
|
+
insertRow(db,'llm_usage',{workflowId,subjectType,attemptId,turnRef,provider,source,at,...fields});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const USAGE_COLUMNS=r=>({responseModel:r.model??null,inputTokens:r.inputTokens??null,outputTokens:r.outputTokens??null,cacheReadTokens:r.cacheReadTokens??null,
|
|
886
|
+
cacheWriteTokens:r.cacheWriteTokens??null,reasoningTokens:r.reasoningTokens??null,costUsd:r.costUsd??null,turns:r.turns??null,toolCalls:r.toolCalls??null,toolErrors:r.toolErrors??null});
|
|
887
|
+
/**
|
|
888
|
+
* The measured usage of one op attempt (scripts/kernel/usage-record.mjs): one llm_usage row per model, the summary on
|
|
889
|
+
* op_attempts (tokens_in = fresh + cache read + cache write, tokens_out, cost_usd only when every model is priced,
|
|
890
|
+
* usage_source) and one 'attempt-usage-recorded' event. An attempt that already has llm_usage rows is left alone, so a
|
|
891
|
+
* re-run never double counts. Returns {recorded, rows}.
|
|
892
|
+
*/
|
|
893
|
+
export function recordAttemptUsage(db,{attemptId,rows,source='cli-transcript',provider=null,requestModel=null,sessions=null,at=nowMs()}){
|
|
894
|
+
const a=db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
895
|
+
need(a,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
896
|
+
need(Array.isArray(rows)&&rows.length,'recordAttemptUsage needs at least one usage row');
|
|
897
|
+
if(db.prepare("SELECT 1 FROM llm_usage WHERE subject_type='attempt' AND attempt_id=? LIMIT 1").get(attemptId))return {recorded:false,rows:0};
|
|
898
|
+
for(const r of rows)recordLlmUsage(db,{workflowId:a.workflow_id,subjectType:'attempt',attemptId,provider:provider??a.provider??a.agent??'unknown',source,at,
|
|
899
|
+
spanId:a.span_id,requestModel:requestModel??a.request_model??a.model??null,...USAGE_COLUMNS(r)});
|
|
900
|
+
const tokensIn=rows.reduce((n,r)=>n+(r.inputTokens??0)+(r.cacheReadTokens??0)+(r.cacheWriteTokens??0),0);
|
|
901
|
+
const tokensOut=rows.reduce((n,r)=>n+(r.outputTokens??0),0);
|
|
902
|
+
const costUsd=rows.every(r=>typeof r.costUsd==='number')?Math.round(rows.reduce((n,r)=>n+r.costUsd,0)*1e6)/1e6:null;
|
|
903
|
+
updateAttempt(db,{attemptId,at,tokensIn,tokensOut,costUsd,usageSource:source,usageReason:null});
|
|
904
|
+
appendEvent(db,{workflowId:a.workflow_id,entityType:'attempt',entityId:String(attemptId),attemptId,spanId:a.span_id,kind:'attempt-usage-recorded',
|
|
905
|
+
payload:{attemptId,source,models:rows.map(r=>r.model),tokensIn,tokensOut,costUsd,...(sessions?{sessions}:{})},createdAt:at}); // sessions: [{session id, matchedBy, where}] - the exact link
|
|
906
|
+
return {recorded:true,rows:rows.length};
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* An attempt whose usage cannot be measured: usage_source 'unavailable' and usage_reason (no usage adapter for its agent, or no
|
|
910
|
+
* session file found after the grace window). Never overwrites a measured attempt; a later measurement (recordAttemptUsage) replaces it.
|
|
911
|
+
* One 'attempt-usage-unavailable' event when the recorded reason changes. Returns {marked}.
|
|
912
|
+
*/
|
|
913
|
+
export function markAttemptUsageUnavailable(db,{attemptId,reason,at=nowMs()}){
|
|
914
|
+
const a=db.prepare('SELECT * FROM op_attempts WHERE attempt_id=?').get(attemptId);
|
|
915
|
+
need(a,`attempt ${attemptId} not found`,'STARCI_ATTEMPT_NOT_FOUND');
|
|
916
|
+
need(reason,'markAttemptUsageUnavailable needs a reason');
|
|
917
|
+
if(db.prepare("SELECT 1 FROM llm_usage WHERE subject_type='attempt' AND attempt_id=? LIMIT 1").get(attemptId))return {marked:false};
|
|
918
|
+
if(a.usage_source==='unavailable'&&a.usage_reason===reason)return {marked:false};
|
|
919
|
+
updateAttempt(db,{attemptId,at,tokensIn:null,tokensOut:null,costUsd:null,usageSource:'unavailable',usageReason:reason});
|
|
920
|
+
appendEvent(db,{workflowId:a.workflow_id,entityType:'attempt',entityId:String(attemptId),attemptId,spanId:a.span_id,kind:'attempt-usage-unavailable',payload:{attemptId,reason},createdAt:at});
|
|
921
|
+
return {marked:true};
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* The usage a Kernel seat's session added since its last recording: one llm_usage row per model, subject 'kernel-turn',
|
|
925
|
+
* turn_ref '<kernel:workflow>:<session>@<turns so far>' (the caller derives it from what the ledger already holds, so a
|
|
926
|
+
* re-run over an unchanged session adds nothing). One 'kernel-usage-recorded' event. Returns {recorded, rows}.
|
|
927
|
+
*/
|
|
928
|
+
export function recordKernelUsage(db,{workflowId,turnRef,rows,provider,source='cli-transcript',requestModel=null,at=nowMs()}){
|
|
929
|
+
need(workflowId&&turnRef&&provider,'recordKernelUsage needs workflowId, turnRef and provider');
|
|
930
|
+
need(db.prepare('SELECT 1 FROM workflows WHERE workflow_id=?').get(workflowId),`workflow ${workflowId} not found`,'STARCI_WORKFLOW_NOT_FOUND');
|
|
931
|
+
if(!Array.isArray(rows)||!rows.length)return {recorded:false,rows:0};
|
|
932
|
+
if(db.prepare("SELECT 1 FROM llm_usage WHERE subject_type='kernel-turn' AND workflow_id=? AND turn_ref=? LIMIT 1").get(workflowId,turnRef))return {recorded:false,rows:0};
|
|
933
|
+
for(const r of rows)recordLlmUsage(db,{workflowId,subjectType:'kernel-turn',turnRef,provider,source,at,requestModel,...USAGE_COLUMNS(r)});
|
|
934
|
+
appendEvent(db,{workflowId,entityType:'workflow',entityId:workflowId,kind:'kernel-usage-recorded',
|
|
935
|
+
payload:{turnRef,source,models:rows.map(r=>r.model),tokens:rows.reduce((n,r)=>n+(r.inputTokens??0)+(r.cacheReadTokens??0)+(r.cacheWriteTokens??0)+(r.outputTokens??0),0)},createdAt:at});
|
|
936
|
+
return {recorded:true,rows:rows.length};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// --- logs ---------------------------------------------------------------------------------------------------------
|
|
940
|
+
export const LOG_ACTORS=Object.freeze(['kernel','op','runtime','check','land','settler','reconciler']);
|
|
941
|
+
export const LOG_LEVELS=Object.freeze(['debug','info','warn','error']);
|
|
942
|
+
/** One typed log row; returns {changes,lastInsertRowid} (changes 0 when `orIgnore` and its src is already stored). */
|
|
943
|
+
export function appendLog(db,{at=nowMs(),workflowId,actor,level='info',kind,msg,data=null,refs=null,jobId=null,attemptId=null,traceId=null,spanId=null,nodeId=null,src=null,orIgnore=src!==null}){
|
|
944
|
+
need(workflowId&&actor&&kind&&typeof msg==='string','appendLog needs workflowId, actor, kind and msg');
|
|
945
|
+
return insertRow(db,'logs',{at,workflowId,jobId,attemptId,traceId,spanId,actor,nodeId,level,kind,msg,dataJson:json(data),refsJson:json(refs),src},{orIgnore});
|
|
946
|
+
}
|
|
947
|
+
/** log_cursors: `mode` 'max' never moves a cursor back. */
|
|
948
|
+
export function setLogCursor(db,{name,value,mode='set'}){
|
|
949
|
+
db.prepare(`INSERT INTO log_cursors(name,value) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET value=${mode==='max'?'max(value,excluded.value)':'excluded.value'}`).run(name,value);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// --- conditions, incidents, inbox, decisions ------------------------------------------------------------------------
|
|
953
|
+
/** Upsert one condition; a status change moves last_transition_at and appends 'condition-changed'. */
|
|
954
|
+
export function setCondition(db,{workflowId,entityType,entityId,type,status,reason,message=null,owner=null,observedGeneration=null,at=nowMs()}){
|
|
955
|
+
const prior=db.prepare('SELECT * FROM conditions WHERE entity_type=? AND entity_id=? AND type=?').get(entityType,String(entityId),type);
|
|
956
|
+
const moved=!prior||prior.status!==status;
|
|
957
|
+
db.prepare(`INSERT INTO conditions(workflow_id,entity_type,entity_id,type,status,reason,message,owner,observed_generation,last_transition_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
|
958
|
+
ON CONFLICT(entity_type,entity_id,type) DO UPDATE SET status=excluded.status,reason=excluded.reason,message=excluded.message,owner=excluded.owner,
|
|
959
|
+
observed_generation=excluded.observed_generation,updated_at=excluded.updated_at,last_transition_at=CASE WHEN conditions.status<>excluded.status THEN excluded.last_transition_at ELSE conditions.last_transition_at END`)
|
|
960
|
+
.run(workflowId,entityType,String(entityId),type,status,reason,message,owner,observedGeneration,at,at);
|
|
961
|
+
if(moved)appendEvent(db,{workflowId,entityType,entityId:String(entityId),kind:'condition-changed',payload:{type,from:prior?.status??null,to:status,reason},createdAt:at});
|
|
962
|
+
return moved;
|
|
963
|
+
}
|
|
964
|
+
export const INCIDENT_KINDS=Object.freeze(['infra-provider','config-defect','owner-ask','credential-missing','safety-block','runtime-defect','evidence-missing','scope-change','partial-effect','other']);
|
|
965
|
+
/**
|
|
966
|
+
* The runtime's free incident kinds (api incident --kind, the '[kind] detail' prefix of last_progress) mapped onto the
|
|
967
|
+
* incidents.kind enum and the owner who must clear it. An enum value maps to itself.
|
|
968
|
+
*/
|
|
969
|
+
export function incidentClassOf(freeKind){
|
|
970
|
+
const k=String(freeKind??'').toLowerCase();
|
|
971
|
+
if(INCIDENT_KINDS.includes(k))return {kind:k,owner:['owner-ask','credential-missing','safety-block','scope-change','partial-effect'].includes(k)?'owner':['runtime-defect','config-defect'].includes(k)?'supervisor':'kernel'};
|
|
972
|
+
if(/owner|handover|approval/.test(k))return {kind:'owner-ask',owner:'owner'};
|
|
973
|
+
if(/credential|secret|provision/.test(k))return {kind:'credential-missing',owner:'owner'};
|
|
974
|
+
if(/safety/.test(k))return {kind:'safety-block',owner:'owner'};
|
|
975
|
+
if(/scope|goal/.test(k))return {kind:'scope-change',owner:'owner'};
|
|
976
|
+
if(/partial|effect/.test(k))return {kind:'partial-effect',owner:'owner'};
|
|
977
|
+
if(/infra|provider|quota|environment|network/.test(k))return {kind:'infra-provider',owner:'kernel'};
|
|
978
|
+
if(/config/.test(k))return {kind:'config-defect',owner:'supervisor'};
|
|
979
|
+
if(/supervisor|runtime|orca|settler|tree|kernel/.test(k))return {kind:'runtime-defect',owner:'supervisor'};
|
|
980
|
+
if(/evidence|proof|capture/.test(k))return {kind:'evidence-missing',owner:'kernel'};
|
|
981
|
+
return {kind:'other',owner:'kernel'};
|
|
982
|
+
}
|
|
983
|
+
/** Open one incident. A free `kind` (peer-wait, owner-gate, ...) is classed by incidentClassOf; `owner` defaults to the class's. */
|
|
984
|
+
export function openIncident(db,{incidentId=`inc-${newToken().slice(0,12)}`,workflowId,kind,owner=null,detail=null,lastProgress=null,dueAt=null,opId=null,jobId=null,attemptId=null,at=nowMs()}){
|
|
985
|
+
const cls=incidentClassOf(kind);kind=cls.kind;owner=owner??cls.owner;
|
|
986
|
+
insertRow(db,'incidents',{incidentId,workflowId,opId,jobId,attemptId,kind,detail,lastProgress,owner,dueAt,status:'open',createdAt:at,updatedAt:at});
|
|
987
|
+
appendEvent(db,{workflowId,entityType:'incident',entityId:incidentId,attemptId,kind:'incident-opened',payload:{kind,owner,detail},createdAt:at});
|
|
988
|
+
return incidentId;
|
|
989
|
+
}
|
|
990
|
+
const INCIDENT_MUTABLE=new Set(['detail','owner','dueAt','attempts','modelCalls','tokens','elapsedMs','lastProgress']);
|
|
991
|
+
export function updateIncident(db,{incidentId,at=nowMs(),...fields}){
|
|
992
|
+
for(const key of Object.keys(fields))need(INCIDENT_MUTABLE.has(key),`updateIncident cannot set ${key}`);
|
|
993
|
+
return updateRow(db,'incidents',{incidentId},{...fields,updatedAt:at}).changes>0;
|
|
994
|
+
}
|
|
995
|
+
export function resolveIncident(db,{incidentId,reason,status='resolved',at=nowMs()}){
|
|
996
|
+
const row=db.prepare('SELECT * FROM incidents WHERE incident_id=?').get(incidentId);
|
|
997
|
+
need(row,`incident ${incidentId} not found`);
|
|
998
|
+
if(row.status!=='open')return false;
|
|
999
|
+
updateRow(db,'incidents',{incidentId},{status,resolvedReason:reason,resolvedAt:at,updatedAt:at});
|
|
1000
|
+
appendEvent(db,{workflowId:row.workflow_id,entityType:'incident',entityId:incidentId,kind:'incident-resolved',payload:{reason,status},createdAt:at});
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
export function postInbox(db,{workflowId,kind,payload,key=null,fromRef=null,attemptId=null,createdAt=nowMs()}){
|
|
1004
|
+
const {lastInsertRowid}=insertRow(db,'inbox',{workflowId,kind,key,fromRef,attemptId,payloadJson:json(payload),status:'pending',createdAt});
|
|
1005
|
+
appendEvent(db,{workflowId,entityType:'inbox',entityId:String(lastInsertRowid),attemptId,kind:'inbox-posted',payload:{kind,key,fromRef},createdAt});
|
|
1006
|
+
return Number(lastInsertRowid);
|
|
1007
|
+
}
|
|
1008
|
+
export function setInboxStatus(db,{inboxId,status,disposition=undefined,at=nowMs()}){
|
|
1009
|
+
const row=db.prepare('SELECT * FROM inbox WHERE inbox_id=?').get(inboxId);need(row,`inbox ${inboxId} not found`);
|
|
1010
|
+
if(row.status===status)return false;
|
|
1011
|
+
const fields={status};if(disposition!==undefined)fields.dispositionJson=json(disposition);if(['applied','done'].includes(status))fields.appliedAt=at;
|
|
1012
|
+
updateRow(db,'inbox',{inboxId},fields);
|
|
1013
|
+
appendEvent(db,{workflowId:row.workflow_id,entityType:'inbox',entityId:String(inboxId),kind:'inbox-status',payload:{from:row.status,to:status},createdAt:at});
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
/** MB-07: one DI per full identity key; an existing open DI with the same key is returned, not duplicated. */
|
|
1017
|
+
export function openDecisionItem(db,{diId=null,idempotencyKey,keyParts,workflowId=null,kind,decider,summary,openedBy,payload={},at=nowMs(),...fields}){
|
|
1018
|
+
const prior=db.prepare('SELECT * FROM decision_items WHERE idempotency_key=?').get(idempotencyKey);
|
|
1019
|
+
if(prior)return prior;
|
|
1020
|
+
const id=diId??`di-${sha256(idempotencyKey).slice(0,8)}`;
|
|
1021
|
+
insertRow(db,'decision_items',{diId:id,idempotencyKey,keyPartsJson:json(keyParts),workflowId,kind,decider,summary,status:'open',openedBy,openedAt:at,payloadJson:json(payload),...fields});
|
|
1022
|
+
if(workflowId)appendEvent(db,{workflowId,entityType:'decision',entityId:id,kind:'decision-opened',payload:{kind,decider,summary},createdAt:at});
|
|
1023
|
+
return db.prepare('SELECT * FROM decision_items WHERE di_id=?').get(id);
|
|
1024
|
+
}
|
|
1025
|
+
const DI_MUTABLE=new Set(['status','dueAt','escalateTo','escalations','claimBy','claimAt','claimTtlMs','resolvedBy','resolvedAt','resolutionVerb','decisionId','supersededBy','evidence','options','allowedVerbs','payload','summary']);
|
|
1026
|
+
export function updateDecisionItem(db,{diId,at=nowMs(),...fields}){
|
|
1027
|
+
for(const key of Object.keys(fields))need(DI_MUTABLE.has(key),`updateDecisionItem cannot set ${key}`);
|
|
1028
|
+
const row=db.prepare('SELECT * FROM decision_items WHERE di_id=?').get(diId);need(row,`decision item ${diId} not found`);
|
|
1029
|
+
updateRow(db,'decision_items',{diId},fields);
|
|
1030
|
+
if(fields.status&&fields.status!==row.status&&row.workflow_id)
|
|
1031
|
+
appendEvent(db,{workflowId:row.workflow_id,entityType:'decision',entityId:diId,kind:'decision-status',payload:{from:row.status,to:fields.status},createdAt:at});
|
|
1032
|
+
}
|
|
1033
|
+
export function recordDecision(db,{decisionId=`dec-${newToken().slice(0,12)}`,workflowId=null,spanId=newSpanId(),parentSpanId=null,decider,diId=null,subjectType=null,subjectId=null,choice,rationale=null,result=null,decidedAt=nowMs()}){
|
|
1034
|
+
insertRow(db,'decisions',{decisionId,workflowId,spanId,parentSpanId,decider,diId,subjectType,subjectId,choice,rationale,resultJson:json(result),decidedAt});
|
|
1035
|
+
if(workflowId)appendEvent(db,{workflowId,entityType:subjectType??'workflow',entityId:subjectId??workflowId,spanId,kind:'decision',payload:{decisionId,choice,decider},createdAt:decidedAt});
|
|
1036
|
+
return decisionId;
|
|
1037
|
+
}
|
|
1038
|
+
/** signals: process fences only. */
|
|
1039
|
+
export function setSignal(db,{scope,key,workflowId=null,holderPid=null,token=null,value=null,expiresAt=null,at=nowMs()}){
|
|
1040
|
+
db.prepare('INSERT INTO signals(scope,key,workflow_id,holder_pid,token,value_json,at,expires_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(scope,key) DO UPDATE SET workflow_id=excluded.workflow_id,holder_pid=excluded.holder_pid,token=excluded.token,value_json=excluded.value_json,at=excluded.at,expires_at=excluded.expires_at')
|
|
1041
|
+
.run(scope,key,workflowId,holderPid,token,json(value),at,expiresAt);
|
|
1042
|
+
}
|
|
1043
|
+
const SIGNAL_MUTABLE=new Set(['holderPid','token','value','expiresAt','at','workflowId']);
|
|
1044
|
+
/** Update fields of an existing signal (only when its token matches, if `token` is given); returns true when a row changed. */
|
|
1045
|
+
export function updateSignal(db,{scope,key,token=undefined,...fields}){
|
|
1046
|
+
for(const k of Object.keys(fields))need(SIGNAL_MUTABLE.has(k),`updateSignal cannot set ${k}`);
|
|
1047
|
+
const where={scope,key};if(token!==undefined)where.token=token;
|
|
1048
|
+
const set={...fields};if('value' in set){set.valueJson=json(set.value);delete set.value;}
|
|
1049
|
+
return updateRow(db,'signals',where,set).changes>0;
|
|
1050
|
+
}
|
|
1051
|
+
export function clearSignal(db,{scope,key,token=null}){
|
|
1052
|
+
return (token?db.prepare('DELETE FROM signals WHERE scope=? AND key=? AND token=?').run(scope,key,token):db.prepare('DELETE FROM signals WHERE scope=? AND key=?').run(scope,key)).changes>0;
|
|
1053
|
+
}
|
|
1054
|
+
/** settle_tails queue (replaces <ledger dir>/settle-tail/<job>.json). */
|
|
1055
|
+
export function queueSettleTail(db,{attemptId,dueAt=null,at=nowMs()}){
|
|
1056
|
+
const a=db.prepare('SELECT workflow_id FROM op_attempts WHERE attempt_id=?').get(attemptId);need(a,`attempt ${attemptId} not found`);
|
|
1057
|
+
insertRow(db,'settle_tails',{attemptId,workflowId:a.workflow_id,state:'queued',dueAt,queuedAt:at},{orIgnore:true});
|
|
1058
|
+
}
|
|
1059
|
+
export function updateSettleTail(db,{attemptId,state,lastError=undefined,dueAt=undefined,at=nowMs()}){
|
|
1060
|
+
const fields={state};if(lastError!==undefined)fields.lastError=lastError;if(dueAt!==undefined)fields.dueAt=dueAt;
|
|
1061
|
+
if(state==='running'){fields.startedAt=at;db.prepare('UPDATE settle_tails SET tries=tries+1 WHERE attempt_id=?').run(attemptId);}
|
|
1062
|
+
if(state==='done')fields.doneAt=at;
|
|
1063
|
+
return updateRow(db,'settle_tails',{attemptId},fields).changes>0;
|
|
1064
|
+
}
|
|
1065
|
+
export function recordProductLand(db,{workflowId,repoRoot,wfBranch,result='queued',spanId=newSpanId(),startedAt=nowMs(),...fields}){
|
|
1066
|
+
const {lastInsertRowid}=insertRow(db,'product_lands',{workflowId,spanId,repoRoot,wfBranch,result,startedAt,...fields});
|
|
1067
|
+
appendEvent(db,{workflowId,entityType:'workflow',entityId:workflowId,spanId,kind:'product-land',payload:{landId:Number(lastInsertRowid),result},createdAt:startedAt});
|
|
1068
|
+
return Number(lastInsertRowid);
|
|
1069
|
+
}
|
|
1070
|
+
export function finishProductLand(db,{landId,result,at=nowMs(),...fields}){
|
|
1071
|
+
const row=db.prepare('SELECT * FROM product_lands WHERE land_id=?').get(landId);need(row,`product land ${landId} not found`);
|
|
1072
|
+
updateRow(db,'product_lands',{landId},{result,finishedAt:at,...fields});
|
|
1073
|
+
appendEvent(db,{workflowId:row.workflow_id,entityType:'workflow',entityId:row.workflow_id,spanId:row.span_id,kind:'product-land',payload:{landId,result},createdAt:at});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// --- shared foundations, declarations, path transfers, record changes (A6) ------------------------------------------
|
|
1077
|
+
const FOUNDATION_KIND_ENUM=new Set(['brand','grammar','layout-tree','shell','module','contract','other']);
|
|
1078
|
+
/**
|
|
1079
|
+
* Upsert one foundations row. `kind` outside the table enum is stored as 'other'; `state` 'landed' is 'published'.
|
|
1080
|
+
* `detail` is the caller's full record (kept as JSON text: the table has no column for dependents and history).
|
|
1081
|
+
*/
|
|
1082
|
+
export function upsertFoundation(db,{name,kind='other',state,ownerWorkflow=null,version=null,detail=null,workRef=null,at=nowMs()}){
|
|
1083
|
+
const k=FOUNDATION_KIND_ENUM.has(kind)?kind:'other',st=state==='landed'?'published':state;
|
|
1084
|
+
const text=detail==null?null:typeof detail==='string'?redactText(detail):JSON.stringify(redactData(detail));
|
|
1085
|
+
db.prepare(`INSERT INTO foundations(name,kind,state,owner_workflow,version,detail,work_ref,updated_at) VALUES(?,?,?,?,?,?,?,?)
|
|
1086
|
+
ON CONFLICT(name) DO UPDATE SET kind=excluded.kind,state=excluded.state,owner_workflow=excluded.owner_workflow,version=excluded.version,
|
|
1087
|
+
detail=excluded.detail,work_ref=excluded.work_ref,updated_at=excluded.updated_at`).run(name,k,st,ownerWorkflow,version,text,workRef,at);
|
|
1088
|
+
}
|
|
1089
|
+
/** A workflow's foundation declaration (builds_none, detail as JSON text). */
|
|
1090
|
+
export function declareFoundations(db,{workflowId,buildsNone,detail=null,at=nowMs()}){
|
|
1091
|
+
const text=detail==null?null:typeof detail==='string'?redactText(detail):JSON.stringify(redactData(detail));
|
|
1092
|
+
db.prepare('INSERT INTO foundation_declarations(workflow_id,builds_none,detail,at) VALUES(?,?,?,?) ON CONFLICT(workflow_id) DO UPDATE SET builds_none=excluded.builds_none,detail=excluded.detail,at=excluded.at')
|
|
1093
|
+
.run(workflowId,buildsNone?1:0,text,at);
|
|
1094
|
+
}
|
|
1095
|
+
/** One path_transfers row (the path's current ownership transfer); `detail` is the full transfer record. */
|
|
1096
|
+
export function recordPathTransfer(db,{path:p,fromWorkflow=null,toWorkflow=null,bridgeId=null,state='applied',detail=null,at=nowMs()}){
|
|
1097
|
+
db.prepare(`INSERT INTO path_transfers(path,from_workflow,to_workflow,bridge_id,state,detail_json,at) VALUES(?,?,?,?,?,?,?)
|
|
1098
|
+
ON CONFLICT(path) DO UPDATE SET from_workflow=excluded.from_workflow,to_workflow=excluded.to_workflow,bridge_id=excluded.bridge_id,
|
|
1099
|
+
state=excluded.state,detail_json=excluded.detail_json,at=excluded.at`).run(p,fromWorkflow,toWorkflow,bridgeId,state,json(detail),at);
|
|
1100
|
+
}
|
|
1101
|
+
/** One record_changes row (append-only history of a Work record's declared changes); `reason` may carry the entry as JSON. */
|
|
1102
|
+
export function recordRecordChange(db,{changeId=`rc-${newToken().slice(0,16)}`,workflowId=null,recordId,recordPath=null,reason=null,by=null,at=nowMs()}){
|
|
1103
|
+
const wf=workflowId&&db.prepare('SELECT 1 FROM workflows WHERE workflow_id=?').get(workflowId)?workflowId:null;
|
|
1104
|
+
insertRow(db,'record_changes',{changeId,workflowId:wf,recordId,recordPath,reason:reason==null||typeof reason==='string'?reason:JSON.stringify(reason),by,at});
|
|
1105
|
+
return changeId;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// --- blob GC hooks (scripts/supervisor/blob-gc.mjs; `dbOrLedger` is a db or an openLedger handle) ---------------------
|
|
1109
|
+
/** Mark a blob archived in this ledger's index (the zip it now lives in: '<zip>!<entry>'). */
|
|
1110
|
+
export function markBlobArchived(dbOrLedger,{sha256:sha,archivedAt=nowMs(),archiveRef}){
|
|
1111
|
+
const db=dbOrLedger.db??dbOrLedger;
|
|
1112
|
+
return db.prepare('UPDATE blobs SET archived_at=?,archive_ref=? WHERE sha256=?').run(archivedAt,archiveRef??null,sha).changes>0;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Drop attempt_transcript_snapshots that are no longer needed: every snapshot of an attempt whose final transcript is
|
|
1116
|
+
* stored (op_attempts.transcript_sha), and any snapshot older than the retention of its attempt's verdict (pass: passMs,
|
|
1117
|
+
* anything else: failMs). Returns the number of rows deleted.
|
|
1118
|
+
*/
|
|
1119
|
+
export function pruneAttemptSnapshots(dbOrLedger,{now=nowMs(),passMs,failMs}){
|
|
1120
|
+
const db=dbOrLedger.db??dbOrLedger;
|
|
1121
|
+
need(Number.isFinite(passMs)&&Number.isFinite(failMs),'pruneAttemptSnapshots needs passMs and failMs');
|
|
1122
|
+
const run=d=>d.prepare(`DELETE FROM attempt_transcript_snapshots WHERE snapshot_id IN (
|
|
1123
|
+
SELECT s.snapshot_id FROM attempt_transcript_snapshots s JOIN op_attempts a ON a.attempt_id=s.attempt_id
|
|
1124
|
+
WHERE a.transcript_sha IS NOT NULL OR s.at < ? - CASE WHEN a.verdict='pass' THEN ? ELSE ? END)`).run(now,passMs,failMs).changes;
|
|
1125
|
+
return dbOrLedger.transaction&&!dbOrLedger.transaction.active?.()?dbOrLedger.transaction(run):run(db);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// --- workflow purge (the one delete path: archive verified first, then one cascading DELETE) --------------------------
|
|
1129
|
+
/** Upsert the workflow_purges tombstone; the table CHECK refuses deleting/purged without approval and a verified archive. */
|
|
1130
|
+
export function recordPurge(db,{workflowId,state,at=nowMs(),...fields}){
|
|
1131
|
+
const prior=db.prepare('SELECT 1 FROM workflow_purges WHERE workflow_id=?').get(workflowId);
|
|
1132
|
+
if(!prior)insertRow(db,'workflow_purges',{workflowId,state,createdAt:at,...fields});
|
|
1133
|
+
else updateRow(db,'workflow_purges',{workflowId},{state,...fields});
|
|
1134
|
+
return db.prepare('SELECT * FROM workflow_purges WHERE workflow_id=?').get(workflowId);
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Delete every row of a workflow: one DELETE on workflows, every workflow table cascading (DBTREE: FK ON DELETE CASCADE).
|
|
1138
|
+
* Only while its workflow_purges row is 'deleting' (the events/logs delete guards open for it then). Returns rows per table.
|
|
1139
|
+
*/
|
|
1140
|
+
export function deleteWorkflowRows(db,{workflowId}){
|
|
1141
|
+
need(db.prepare("SELECT state FROM workflow_purges WHERE workflow_id=?").get(workflowId)?.state==='deleting',`purge-not-deleting: ${workflowId} has no workflow_purges row in state deleting`,'STARCI_PURGE_NOT_DELETING');
|
|
1142
|
+
const tables=db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name<>'workflow_purges'").all().map(r=>r.name)
|
|
1143
|
+
.filter(t=>db.prepare(`PRAGMA table_info(${t})`).all().some(c=>c.name==='workflow_id'));
|
|
1144
|
+
const counts=Object.fromEntries(tables.map(t=>[t,Number(db.prepare(`SELECT count(*) n FROM ${t} WHERE workflow_id=?`).get(workflowId).n)]));
|
|
1145
|
+
db.prepare('DELETE FROM workflows WHERE workflow_id=?').run(workflowId);
|
|
1146
|
+
return counts;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Every typed write, for the handle's `write` namespace. */
|
|
1150
|
+
export const LEDGER_WRITES=Object.freeze({recordBlob,storeBlob,appendEvent,createWorkflow,ensureWorkflow,changeWorkflowPhase,updateWorkflow,insertGoal,recordGoalInput,
|
|
1151
|
+
createUnit,setUnitState,reopenUnit,raiseTryBudget,addUnitEdge,recordGraphVersion,enqueueJob,setJobStatus,updateJob,startAttempt,updateAttempt,endRejectedAttempt,writeContract,
|
|
1152
|
+
declareResource,acquireLease,renewLeases,releaseLeases,idempotent,recordFailedRequest,fileReport,markReportConsumed,recordCheckRun,recordArtifact,attachToReport,
|
|
1153
|
+
recordArtifactProof,citeBlob,recordTranscriptSnapshot,setAttemptTranscript,recordLlmUsage,recordAttemptUsage,markAttemptUsageUnavailable,recordKernelUsage,appendLog,setLogCursor,setCondition,openIncident,updateIncident,resolveIncident,
|
|
1154
|
+
postInbox,setInboxStatus,setInboxStatusByKey,updateGoalJson,openDecisionItem,updateDecisionItem,recordDecision,setSignal,updateSignal,clearSignal,queueSettleTail,recordJobResult,bindKernelJob,releaseKernelJob,recordPurge,deleteWorkflowRows,markBlobArchived,pruneAttemptSnapshots,upsertFoundation,declareFoundations,recordPathTransfer,recordRecordChange,updateSettleTail,recordProductLand,finishProductLand});
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* The read-write handle. A new (empty) file is created with 0001-init.sql; any other schema is refused (clean slate).
|
|
1158
|
+
* `checkpointer:true` is the one connection that checkpoints (the reconciler engine): wal_autocheckpoint=8000 and
|
|
1159
|
+
* handle.checkpoint() for the periodic PASSIVE checkpoint; every other connection runs wal_autocheckpoint=0.
|
|
1160
|
+
* `write.<fn>(args)` runs one typed write in its own transaction; inside handle.transaction(db=>…) call the exported
|
|
1161
|
+
* functions with that db.
|
|
1162
|
+
*/
|
|
1163
|
+
export function openLedger({file,now=Date.now,busyTimeoutMs=LEDGER_BUSY_TIMEOUT_MS,repoRoot=null,product=null,checkpointer=false,machine=null}={}){
|
|
1164
|
+
const pragmas=checkpointer?{...LEDGER_PRAGMAS,wal_autocheckpoint:CHECKPOINTER_AUTOCHECKPOINT}:LEDGER_PRAGMAS;
|
|
1165
|
+
const {db,sqliteVersion,journalMode}=openDb({file,busyTimeoutMs,journalMode:'WAL',autoVacuum:true,label:'openLedger',pragmas});
|
|
1166
|
+
let created=false;
|
|
1167
|
+
try{
|
|
1168
|
+
created=initLedger(db,{file,now,sqliteVersion,journalMode,repoRoot:repoRoot??repoRootOfFile.get(path.resolve(file))??null,product});
|
|
1169
|
+
verifyLedger(db,{file,sqliteVersion});
|
|
1170
|
+
upgradeAwaitingOwner(db,{now});
|
|
1171
|
+
migrateLedger(db,{file,now,runtimeRev:runtimeRev(),backup:!created});
|
|
1172
|
+
const meta=metaOf(db);
|
|
1173
|
+
if(meta.sqlite_version!==sqliteVersion)db.prepare("UPDATE meta SET value=? WHERE key='sqlite_version'").run(sqliteVersion);
|
|
1174
|
+
}catch(error){try{db.close();}catch{}throw error;}
|
|
1175
|
+
const transaction=makeTransaction(db,'ledger');
|
|
1176
|
+
const resolved=path.resolve(file),ledgerId=ledgerIdOf({db});
|
|
1177
|
+
// A new ledger enrols itself in machine.ledgers (repo_root from its meta); a refusal (temp file on the live registry) is fine.
|
|
1178
|
+
if(created){const own=metaOf(db);if(own.repo_root)try{withMachine(m=>m.registerLedger({ledgerId,file:resolved,repoRoot:own.repo_root,product:own.product??null,schemaVersion:LEDGER_VERSION}));}catch{/* registry unavailable: resolved by name until it is */}}
|
|
1179
|
+
if(machine?.registerLedger)machine.registerLedger({ledgerId,file:resolved});
|
|
1180
|
+
const inTx=fn=>transaction.active()?fn(db):transaction(fn);
|
|
1181
|
+
const write=Object.fromEntries(Object.entries(LEDGER_WRITES).map(([name,fn])=>[name,
|
|
1182
|
+
name==='idempotent'?(args,body)=>inTx(tx=>fn(tx,{at:now(),...args},body)):args=>inTx(tx=>fn(tx,args??{}))]));
|
|
1183
|
+
return {
|
|
1184
|
+
schema:LEDGER_SCHEMA,file,path:resolved,sqliteVersion,journalMode,db,now,transaction,ledgerId,write,checkpointer,
|
|
1185
|
+
ensureWorkflow(args={}){return inTx(tx=>ensureWorkflow(tx,{at:now(),...args}));},
|
|
1186
|
+
appendEvent(args){return inTx(tx=>appendEvent(tx,{createdAt:now(),...args}));},
|
|
1187
|
+
enqueueJob(args){return inTx(tx=>enqueueJob(tx,{createdAt:now(),...args}));},
|
|
1188
|
+
/** PASSIVE checkpoint: only the checkpointer connection may run it. */
|
|
1189
|
+
checkpoint(){need(checkpointer,'only the checkpointer connection checkpoints');return db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();},
|
|
1190
|
+
...readAccessors(db),
|
|
1191
|
+
close(){db.close();}
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
/** A read-write connection to an EXISTING ledger (the typed-log writer's own connection), verified, never initialised. */
|
|
1195
|
+
export function openLedgerConnection(file,{busyTimeoutMs=LEDGER_BUSY_TIMEOUT_MS}={}){
|
|
1196
|
+
need(fs.existsSync(file),`openLedgerConnection needs an existing ledger: ${file}`);
|
|
1197
|
+
const {db,sqliteVersion}=openDb({file,busyTimeoutMs,journalMode:'WAL',label:'openLedgerConnection'});
|
|
1198
|
+
try{verifyLedger(db,{file,sqliteVersion});migrateLedger(db,{file,now:Date.now,runtimeRev:runtimeRev()});}catch(error){try{db.close();}catch{}throw error;}
|
|
1199
|
+
return db;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Admission: in one ledger transaction, refuse on a durable path-lease overlap or a full repo resource, else move the job
|
|
1204
|
+
* ready → leased with its fencing token and write its leases. The ledger is registered on the machine registry first.
|
|
1205
|
+
*/
|
|
1206
|
+
export function reserveTwoPhase(ledger,machine,{job,leases=[],ttlMs=60000,canonicalOf=null}={}){
|
|
1207
|
+
need(ledger?.transaction&&ledger?.db,'reserveTwoPhase needs a ledger handle');
|
|
1208
|
+
need(machine?.registerLedger,'reserveTwoPhase needs a machine handle');
|
|
1209
|
+
need(job?.jobId&&job?.workflowId,'Job identity is required');
|
|
1210
|
+
const merge=list=>{const byKey=new Map();for(const item of list){need(item?.resourceKey&&Number.isInteger(item.units)&&item.units>0,'Invalid resource request');const prev=byKey.get(item.resourceKey);byKey.set(item.resourceKey,{resourceKey:item.resourceKey,units:(prev?.units??0)+item.units,ttlMs:item.ttlMs??prev?.ttlMs??null});}return [...byKey.values()];};
|
|
1211
|
+
const repoNeeds=merge(leases);
|
|
1212
|
+
machine.registerLedger({file:ledger.path,ledgerId:ledger.ledgerId??ledgerIdOf(ledger)});
|
|
1213
|
+
return ledger.transaction(db=>{
|
|
1214
|
+
const at=ledger.now(),token=newToken();
|
|
1215
|
+
const reasons=[];
|
|
1216
|
+
const pathConflicts=findOwnedPathLeaseConflicts(db,repoNeeds,{canonicalOf});
|
|
1217
|
+
for(const conflict of pathConflicts)reasons.push(`resource ${conflict.requested} overlaps durable lease ${conflict.held} held by ${conflict.job_id}`);
|
|
1218
|
+
for(const item of repoNeeds){
|
|
1219
|
+
const row=db.prepare('SELECT capacity FROM resources WHERE resource_key=?').get(item.resourceKey);
|
|
1220
|
+
const capacity=row?.capacity??1;
|
|
1221
|
+
const used=db.prepare('SELECT COALESCE(SUM(units),0) u FROM leases WHERE resource_key=? AND expires_at>?').get(item.resourceKey,at).u;
|
|
1222
|
+
if(used+item.units>capacity)reasons.push(`resource ${item.resourceKey} capacity ${capacity} has ${used} used and needs ${item.units}`);
|
|
1223
|
+
}
|
|
1224
|
+
if(reasons.length)return {ok:false,reason:reasons.join('; '),reasons,pathConflicts:pathConflicts.map(({requested,held,job_id,workflow_id,op_id,expires_at})=>({requested,held,jobId:job_id,workflowId:workflow_id,opId:op_id,expiresAt:expires_at}))};
|
|
1225
|
+
const existing=db.prepare('SELECT * FROM jobs WHERE job_id=?').get(job.jobId);
|
|
1226
|
+
need(existing,`job ${job.jobId} is not enqueued`,'STARCI_JOB_NOT_FOUND');
|
|
1227
|
+
need(existing.workflow_id===job.workflowId,'Reservation identity does not match the durable job');
|
|
1228
|
+
if(existing.status==='queued')setJobStatus(db,{jobId:job.jobId,to:'ready',reason:'admission',at});
|
|
1229
|
+
setJobStatus(db,{jobId:job.jobId,to:'leased',reason:'admission',leaseToken:token,deadline:at+ttlMs,at,expect:['ready','queued']});
|
|
1230
|
+
for(const item of repoNeeds)acquireLease(db,{resourceKey:item.resourceKey,jobId:job.jobId,units:item.units,expiresAt:at+(item.ttlMs??ttlMs),at});
|
|
1231
|
+
return {ok:true,leaseToken:token,expiresAt:at+ttlMs,fencing:Object.fromEntries(repoNeeds.map(item=>[item.resourceKey,existing.generation]))};
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
/** The mirror of reserveTwoPhase: drop the job's lease rows and, with `status`, move the job (a terminal status also drops leases). */
|
|
1235
|
+
export function releaseTwoPhase(ledger,machine,{jobId,status=null,reason=null}={}){
|
|
1236
|
+
need(ledger?.transaction,'releaseTwoPhase needs a ledger handle');
|
|
1237
|
+
need(jobId,'releaseTwoPhase needs a job id');
|
|
1238
|
+
const released=ledger.transaction(db=>{
|
|
1239
|
+
const n=releaseLeases(db,{jobId});
|
|
1240
|
+
if(status)setJobStatus(db,{jobId,to:status,reason,leaseToken:null,deadline:null,at:ledger.now()});
|
|
1241
|
+
else updateJob(db,{jobId,leaseToken:null,deadline:null,at:ledger.now()});
|
|
1242
|
+
return n;
|
|
1243
|
+
});
|
|
1244
|
+
return {ok:true,released};
|
|
1245
|
+
}
|