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