@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,1713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local BuildStore (SPEC §7.2.1): SQLite + blob directory beneath an
|
|
3
|
+
* explicitly resolved local state root. Repository/default selection belongs
|
|
4
|
+
* to the CLI; this adapter only opens the path it is given.
|
|
5
|
+
*
|
|
6
|
+
* Every write goes through `validateEventWrite` (§8 — the enforced ontology).
|
|
7
|
+
* Seq assignment and `appendWithArtifacts` are transactional: bun:sqlite
|
|
8
|
+
* transactions are synchronous, so the atomic path (D6) runs inside one
|
|
9
|
+
* `db.transaction` while blob writes happen *before* it — a rolled-back
|
|
10
|
+
* deposit may orphan a blob, which is harmless because blobs are
|
|
11
|
+
* content-addressed.
|
|
12
|
+
*/
|
|
13
|
+
import { Database } from 'bun:sqlite'
|
|
14
|
+
import { and, asc, desc, eq, gt, inArray, sql } from 'drizzle-orm'
|
|
15
|
+
import { drizzle, type BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite'
|
|
16
|
+
import { mkdirSync } from 'node:fs'
|
|
17
|
+
import { access, copyFile, mkdtemp, rm } from 'node:fs/promises'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { humanActor } from '../../events/envelope'
|
|
21
|
+
import {
|
|
22
|
+
validateEventWrite,
|
|
23
|
+
type AbEvent,
|
|
24
|
+
type EventEnvelope,
|
|
25
|
+
type EventWrite,
|
|
26
|
+
} from '../../events/catalog'
|
|
27
|
+
import type { EventType } from '../../events/payloads'
|
|
28
|
+
import {
|
|
29
|
+
validateRepositoryEventWrite,
|
|
30
|
+
type RepositoryEvent,
|
|
31
|
+
type RepositoryEventEnvelope,
|
|
32
|
+
type RepositoryEventType,
|
|
33
|
+
type RepositoryEventWrite,
|
|
34
|
+
} from '../../events/repository'
|
|
35
|
+
import {
|
|
36
|
+
validateSessionEventWrite,
|
|
37
|
+
type SessionEvent,
|
|
38
|
+
type SessionEventEnvelope,
|
|
39
|
+
type SessionEventType,
|
|
40
|
+
type SessionEventWrite,
|
|
41
|
+
} from '../../events/sessions'
|
|
42
|
+
import { createBuildScopedStore } from '../build-scope'
|
|
43
|
+
import { createSessionScopedStore } from '../session-handle'
|
|
44
|
+
import {
|
|
45
|
+
DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
|
|
46
|
+
isRetentionManagedKind,
|
|
47
|
+
revisionsToPrune,
|
|
48
|
+
} from '../retention'
|
|
49
|
+
import { pollingSubscribe } from '../subscribe'
|
|
50
|
+
import { StreamLocks } from '../streams/lock'
|
|
51
|
+
import { assembleUIMessageDocument } from '../streams/assemble'
|
|
52
|
+
import { readEventsWithWait, readStreamWithWait } from '../streams/wait'
|
|
53
|
+
import {
|
|
54
|
+
serializedBatchSize,
|
|
55
|
+
STREAM_BATCH_MAX_BYTES,
|
|
56
|
+
STREAM_FORMAT,
|
|
57
|
+
StreamBatchTooLargeError,
|
|
58
|
+
StreamClosedError,
|
|
59
|
+
streamArtifactInput,
|
|
60
|
+
validateStreamParts,
|
|
61
|
+
type StreamChunk,
|
|
62
|
+
type StreamOutcome,
|
|
63
|
+
type StreamPart,
|
|
64
|
+
type StreamRead,
|
|
65
|
+
type StreamRecord,
|
|
66
|
+
type StreamScope,
|
|
67
|
+
} from '../streams/types'
|
|
68
|
+
import {
|
|
69
|
+
contentHash,
|
|
70
|
+
normalizeOperator,
|
|
71
|
+
systemClock,
|
|
72
|
+
toBytes,
|
|
73
|
+
validateExpectedSeq,
|
|
74
|
+
type Artifact,
|
|
75
|
+
type ArtifactInput,
|
|
76
|
+
type ArtifactMeta,
|
|
77
|
+
type BlobStore,
|
|
78
|
+
type BuildRecord,
|
|
79
|
+
type BuildScopedStore,
|
|
80
|
+
type BuildStore,
|
|
81
|
+
type Clock,
|
|
82
|
+
type NewBuildInput,
|
|
83
|
+
type NewSessionInput,
|
|
84
|
+
type RepositoryArtifact,
|
|
85
|
+
type RepositoryArtifactMeta,
|
|
86
|
+
type RepositoryRecord,
|
|
87
|
+
type SessionArtifact,
|
|
88
|
+
type SessionArtifactMeta,
|
|
89
|
+
type SessionRecord,
|
|
90
|
+
type SessionScopedStore,
|
|
91
|
+
type SubscribeOptions,
|
|
92
|
+
type Unsubscribe,
|
|
93
|
+
} from '../types'
|
|
94
|
+
import { DirBlobStore } from './blobs'
|
|
95
|
+
import {
|
|
96
|
+
artifacts,
|
|
97
|
+
builds,
|
|
98
|
+
events,
|
|
99
|
+
repoArtifacts,
|
|
100
|
+
repoEvents,
|
|
101
|
+
repoStreams,
|
|
102
|
+
sessionArtifacts,
|
|
103
|
+
sessionEvents,
|
|
104
|
+
sessions,
|
|
105
|
+
streamChunks,
|
|
106
|
+
streams,
|
|
107
|
+
} from './schema'
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Bootstrap DDL, applied idempotently at open. MUST match `schema.ts` —
|
|
111
|
+
* the drizzle schema is the source of truth; this is its inlined form so
|
|
112
|
+
* opening a store never needs a migration step.
|
|
113
|
+
*/
|
|
114
|
+
const BOOTSTRAP_DDL = [
|
|
115
|
+
`CREATE TABLE IF NOT EXISTS builds (
|
|
116
|
+
slug TEXT PRIMARY KEY,
|
|
117
|
+
repo TEXT NOT NULL,
|
|
118
|
+
repo_origin TEXT,
|
|
119
|
+
ticket TEXT,
|
|
120
|
+
branch TEXT,
|
|
121
|
+
created_at TEXT NOT NULL,
|
|
122
|
+
updated_at TEXT NOT NULL,
|
|
123
|
+
lease_holder TEXT,
|
|
124
|
+
lease_expires_at TEXT,
|
|
125
|
+
lease_ttl_ms INTEGER,
|
|
126
|
+
heartbeat_at TEXT
|
|
127
|
+
)`,
|
|
128
|
+
`CREATE TABLE IF NOT EXISTS events (
|
|
129
|
+
build TEXT NOT NULL,
|
|
130
|
+
seq INTEGER NOT NULL,
|
|
131
|
+
ts TEXT NOT NULL,
|
|
132
|
+
actor TEXT NOT NULL,
|
|
133
|
+
type TEXT NOT NULL,
|
|
134
|
+
payload TEXT NOT NULL,
|
|
135
|
+
PRIMARY KEY (build, seq)
|
|
136
|
+
)`,
|
|
137
|
+
`CREATE TABLE IF NOT EXISTS artifacts (
|
|
138
|
+
build TEXT NOT NULL,
|
|
139
|
+
kind TEXT NOT NULL,
|
|
140
|
+
revision INTEGER NOT NULL,
|
|
141
|
+
blob_ref TEXT NOT NULL,
|
|
142
|
+
metadata TEXT NOT NULL,
|
|
143
|
+
created_at TEXT NOT NULL,
|
|
144
|
+
PRIMARY KEY (build, kind, revision)
|
|
145
|
+
)`,
|
|
146
|
+
`CREATE TABLE IF NOT EXISTS repo_streams (
|
|
147
|
+
repo TEXT PRIMARY KEY,
|
|
148
|
+
created_at TEXT NOT NULL,
|
|
149
|
+
updated_at TEXT NOT NULL,
|
|
150
|
+
lease_holder TEXT,
|
|
151
|
+
lease_expires_at TEXT,
|
|
152
|
+
lease_ttl_ms INTEGER,
|
|
153
|
+
heartbeat_at TEXT
|
|
154
|
+
)`,
|
|
155
|
+
`CREATE TABLE IF NOT EXISTS repo_events (
|
|
156
|
+
repo TEXT NOT NULL,
|
|
157
|
+
seq INTEGER NOT NULL,
|
|
158
|
+
ts TEXT NOT NULL,
|
|
159
|
+
actor TEXT NOT NULL,
|
|
160
|
+
type TEXT NOT NULL,
|
|
161
|
+
payload TEXT NOT NULL,
|
|
162
|
+
PRIMARY KEY (repo, seq)
|
|
163
|
+
)`,
|
|
164
|
+
`CREATE TABLE IF NOT EXISTS repo_artifacts (
|
|
165
|
+
repo TEXT NOT NULL,
|
|
166
|
+
kind TEXT NOT NULL,
|
|
167
|
+
revision INTEGER NOT NULL,
|
|
168
|
+
blob_ref TEXT NOT NULL,
|
|
169
|
+
metadata TEXT NOT NULL,
|
|
170
|
+
created_at TEXT NOT NULL,
|
|
171
|
+
PRIMARY KEY (repo, kind, revision)
|
|
172
|
+
)`,
|
|
173
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
repo TEXT NOT NULL,
|
|
176
|
+
operator TEXT NOT NULL,
|
|
177
|
+
title TEXT,
|
|
178
|
+
creation_seq INTEGER NOT NULL,
|
|
179
|
+
created_at TEXT NOT NULL,
|
|
180
|
+
updated_at TEXT NOT NULL
|
|
181
|
+
)`,
|
|
182
|
+
`CREATE TABLE IF NOT EXISTS session_events (
|
|
183
|
+
session TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
184
|
+
seq INTEGER NOT NULL,
|
|
185
|
+
ts TEXT NOT NULL,
|
|
186
|
+
actor TEXT NOT NULL,
|
|
187
|
+
type TEXT NOT NULL,
|
|
188
|
+
payload TEXT NOT NULL,
|
|
189
|
+
PRIMARY KEY (session, seq)
|
|
190
|
+
)`,
|
|
191
|
+
`CREATE TABLE IF NOT EXISTS session_artifacts (
|
|
192
|
+
session TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
193
|
+
kind TEXT NOT NULL,
|
|
194
|
+
revision INTEGER NOT NULL,
|
|
195
|
+
blob_ref TEXT NOT NULL,
|
|
196
|
+
metadata TEXT NOT NULL,
|
|
197
|
+
created_at TEXT NOT NULL,
|
|
198
|
+
PRIMARY KEY (session, kind, revision)
|
|
199
|
+
)`,
|
|
200
|
+
`CREATE TABLE IF NOT EXISTS streams (
|
|
201
|
+
id TEXT PRIMARY KEY,
|
|
202
|
+
scope_kind TEXT NOT NULL CHECK (scope_kind IN ('build','repo','session')),
|
|
203
|
+
build TEXT,
|
|
204
|
+
repo TEXT,
|
|
205
|
+
session TEXT,
|
|
206
|
+
label TEXT NOT NULL,
|
|
207
|
+
format TEXT NOT NULL,
|
|
208
|
+
status TEXT NOT NULL CHECK (status IN ('open','closed')),
|
|
209
|
+
outcome TEXT,
|
|
210
|
+
artifact_kind TEXT,
|
|
211
|
+
artifact_revision INTEGER,
|
|
212
|
+
artifact_blob_ref TEXT,
|
|
213
|
+
created_at TEXT NOT NULL,
|
|
214
|
+
closed_at TEXT,
|
|
215
|
+
creation_seq INTEGER NOT NULL,
|
|
216
|
+
CHECK (
|
|
217
|
+
(scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
|
|
218
|
+
OR
|
|
219
|
+
(scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
|
|
220
|
+
OR
|
|
221
|
+
(scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
|
|
222
|
+
)
|
|
223
|
+
)`,
|
|
224
|
+
`CREATE TABLE IF NOT EXISTS stream_chunks (
|
|
225
|
+
stream TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
|
|
226
|
+
seq INTEGER NOT NULL,
|
|
227
|
+
ts TEXT NOT NULL,
|
|
228
|
+
parts TEXT NOT NULL,
|
|
229
|
+
PRIMARY KEY (stream, seq)
|
|
230
|
+
)`,
|
|
231
|
+
] as const
|
|
232
|
+
|
|
233
|
+
type BuildRow = typeof builds.$inferSelect
|
|
234
|
+
type RepoRow = typeof repoStreams.$inferSelect
|
|
235
|
+
|
|
236
|
+
interface PreparedArtifact {
|
|
237
|
+
kind: string
|
|
238
|
+
blobRef: string
|
|
239
|
+
metadata: Record<string, unknown>
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface SqliteBuildStoreOptions {
|
|
243
|
+
database: Database
|
|
244
|
+
blobs: BlobStore
|
|
245
|
+
clock?: Clock
|
|
246
|
+
/** Artifact retention (store/retention.ts): how many newest revisions of
|
|
247
|
+
* each retention-managed dispatcher kind survive. Default 200. */
|
|
248
|
+
retention?: { maxRevisions?: number }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export class SqliteBuildStore implements BuildStore {
|
|
252
|
+
private readonly sqlite: Database
|
|
253
|
+
private readonly db: BunSQLiteDatabase
|
|
254
|
+
/** Per-stream in-process mutex (store/streams/lock.ts): serializes a
|
|
255
|
+
* stream's close against same-process appends (AUT-348). Cross-connection
|
|
256
|
+
* writers are arbitrated by the commit transaction's chunk re-verification. */
|
|
257
|
+
private readonly streamLocks = new StreamLocks()
|
|
258
|
+
private readonly clock: Clock
|
|
259
|
+
private readonly maxRevisions: number
|
|
260
|
+
readonly blobs: BlobStore
|
|
261
|
+
|
|
262
|
+
constructor(opts: SqliteBuildStoreOptions) {
|
|
263
|
+
this.sqlite = opts.database
|
|
264
|
+
this.blobs = opts.blobs
|
|
265
|
+
this.clock = opts.clock ?? systemClock
|
|
266
|
+
this.maxRevisions = opts.retention?.maxRevisions ?? DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS
|
|
267
|
+
// busy_timeout first so even the WAL switch below waits out a concurrent
|
|
268
|
+
// opener instead of failing fast; WAL so connections on the same file see
|
|
269
|
+
// each other's writes (§7.2.1). Cross-process write serialization comes
|
|
270
|
+
// from `writeTx` (BEGIN IMMEDIATE), not from this timeout alone.
|
|
271
|
+
this.sqlite.exec('PRAGMA busy_timeout = 5000')
|
|
272
|
+
this.sqlite.exec('PRAGMA journal_mode = WAL')
|
|
273
|
+
for (const ddl of BOOTSTRAP_DDL) this.sqlite.exec(ddl)
|
|
274
|
+
// Stores created before `repo_origin` existed keep working: add the column
|
|
275
|
+
// idempotently when a pre-existing table lacks it (the bootstrap DDL above
|
|
276
|
+
// covers fresh stores). No migration framework — one guarded ALTER at open.
|
|
277
|
+
const columns = this.sqlite.query("PRAGMA table_info('builds')").all() as Array<{
|
|
278
|
+
name: string
|
|
279
|
+
}>
|
|
280
|
+
if (!columns.some((column) => column.name === 'repo_origin')) {
|
|
281
|
+
this.sqlite.exec('ALTER TABLE builds ADD COLUMN repo_origin TEXT')
|
|
282
|
+
}
|
|
283
|
+
// Stores created before session-scoped streams existed keep working: add
|
|
284
|
+
// the streams.session column idempotently when a pre-existing table lacks
|
|
285
|
+
// it (the repo_origin precedent). A pre-existing local database also keeps
|
|
286
|
+
// its old `scope_kind` CHECK, so it cannot host session-scoped streams —
|
|
287
|
+
// unreachable in product terms because sessions are hosted-only and
|
|
288
|
+
// nothing local creates one; fresh stores (and every contract-suite
|
|
289
|
+
// database) get the widened DDL above. Rebuilding the table to widen a
|
|
290
|
+
// CHECK would risk local data for an unreachable path and is deliberately
|
|
291
|
+
// not attempted.
|
|
292
|
+
const streamColumns = this.sqlite.query("PRAGMA table_info('streams')").all() as Array<{
|
|
293
|
+
name: string
|
|
294
|
+
}>
|
|
295
|
+
if (!streamColumns.some((column) => column.name === 'session')) {
|
|
296
|
+
this.sqlite.exec('ALTER TABLE streams ADD COLUMN session TEXT')
|
|
297
|
+
}
|
|
298
|
+
// Stores created before the listStreams creation-order tiebreak existed
|
|
299
|
+
// keep working: add the streams.creation_seq column idempotently when a
|
|
300
|
+
// pre-existing table lacks it (the sessions.creation_seq precedent), then
|
|
301
|
+
// backfill by rowid — rowid equals insertion order because streams are
|
|
302
|
+
// never deleted (retention prunes stream_chunks only), so the backfill is
|
|
303
|
+
// consistent with the pinned creation-order contract. Legacy
|
|
304
|
+
// same-millisecond ties are genuinely unorderable; the pinned guarantee
|
|
305
|
+
// applies from the migrated store onward.
|
|
306
|
+
if (!streamColumns.some((column) => column.name === 'creation_seq')) {
|
|
307
|
+
this.sqlite.exec('ALTER TABLE streams ADD COLUMN creation_seq INTEGER NOT NULL DEFAULT 0')
|
|
308
|
+
this.sqlite.exec('UPDATE streams SET creation_seq = rowid')
|
|
309
|
+
}
|
|
310
|
+
// Stores created before the listSessions creation-order tiebreak existed
|
|
311
|
+
// keep working: add the sessions.creation_seq column idempotently when a
|
|
312
|
+
// pre-existing table lacks it (the repo_origin precedent), then backfill
|
|
313
|
+
// by rowid — rowid equals insertion order because sessions are never
|
|
314
|
+
// deleted, so the backfill is consistent with the pinned creation-order
|
|
315
|
+
// contract. Legacy same-millisecond ties are genuinely unorderable; the
|
|
316
|
+
// pinned guarantee applies from the migrated store onward.
|
|
317
|
+
const sessionColumns = this.sqlite.query("PRAGMA table_info('sessions')").all() as Array<{
|
|
318
|
+
name: string
|
|
319
|
+
}>
|
|
320
|
+
if (!sessionColumns.some((column) => column.name === 'creation_seq')) {
|
|
321
|
+
this.sqlite.exec('ALTER TABLE sessions ADD COLUMN creation_seq INTEGER NOT NULL DEFAULT 0')
|
|
322
|
+
this.sqlite.exec('UPDATE sessions SET creation_seq = rowid')
|
|
323
|
+
}
|
|
324
|
+
this.db = drizzle(this.sqlite)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
scopeBuild(slug: string): BuildScopedStore {
|
|
328
|
+
return createBuildScopedStore(this, slug)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
scopeSession(id: string): SessionScopedStore {
|
|
332
|
+
return createSessionScopedStore(this, id)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private now(): string {
|
|
336
|
+
return this.clock().toISOString()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Every write runs BEGIN IMMEDIATE. The store is the only coordination
|
|
341
|
+
* surface (§15.2.7 [D2]), so cross-process writers are the norm: the
|
|
342
|
+
* dispatcher, per-build runners, and the agent's `ab` CLI all open this
|
|
343
|
+
* file (§3.3). A deferred transaction opens on a WAL read snapshot; when
|
|
344
|
+
* another process commits first, the read-to-write upgrade fails with
|
|
345
|
+
* SQLITE_BUSY *without consulting the busy handler*, losing the write.
|
|
346
|
+
* Taking the write lock at BEGIN makes contending writers queue on
|
|
347
|
+
* `busy_timeout` instead.
|
|
348
|
+
*/
|
|
349
|
+
private writeTx<T>(fn: () => T): T {
|
|
350
|
+
return this.db.transaction(fn, { behavior: 'immediate' })
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private buildRow(slug: string): BuildRow | undefined {
|
|
354
|
+
return this.db.select().from(builds).where(eq(builds.slug, slug)).get()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private requireBuild(slug: string): BuildRow {
|
|
358
|
+
const row = this.buildRow(slug)
|
|
359
|
+
if (!row) throw new Error(`unknown build "${slug}"`)
|
|
360
|
+
return row
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private repoRow(repo: string): RepoRow | undefined {
|
|
364
|
+
return this.db.select().from(repoStreams).where(eq(repoStreams.repo, repo)).get()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private requireRepo(repo: string): RepoRow {
|
|
368
|
+
const row = this.repoRow(repo)
|
|
369
|
+
if (!row) throw new Error(`unknown repo "${repo}"`)
|
|
370
|
+
return row
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private toRepoRecord(row: RepoRow): RepositoryRecord {
|
|
374
|
+
return {
|
|
375
|
+
repo: row.repo,
|
|
376
|
+
createdAt: row.createdAt,
|
|
377
|
+
updatedAt: row.updatedAt,
|
|
378
|
+
...(row.heartbeatAt ? { heartbeatAt: row.heartbeatAt } : {}),
|
|
379
|
+
...(row.leaseHolder && row.leaseExpiresAt
|
|
380
|
+
? { lease: { holder: row.leaseHolder, expiresAt: row.leaseExpiresAt } }
|
|
381
|
+
: {}),
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private toRecord(row: BuildRow): BuildRecord {
|
|
386
|
+
return {
|
|
387
|
+
slug: row.slug,
|
|
388
|
+
repo: row.repo,
|
|
389
|
+
...(row.ticket ? { ticket: row.ticket } : {}),
|
|
390
|
+
...(row.branch ? { branch: row.branch } : {}),
|
|
391
|
+
...(row.repoOrigin ? { repoOrigin: row.repoOrigin } : {}),
|
|
392
|
+
createdAt: row.createdAt,
|
|
393
|
+
updatedAt: row.updatedAt,
|
|
394
|
+
...(row.heartbeatAt ? { heartbeatAt: row.heartbeatAt } : {}),
|
|
395
|
+
...(row.leaseHolder && row.leaseExpiresAt
|
|
396
|
+
? { lease: { holder: row.leaseHolder, expiresAt: row.leaseExpiresAt } }
|
|
397
|
+
: {}),
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async createBuild(input: NewBuildInput): Promise<BuildRecord> {
|
|
402
|
+
const ts = this.now()
|
|
403
|
+
return this.writeTx(() => {
|
|
404
|
+
if (this.buildRow(input.slug)) {
|
|
405
|
+
throw new Error(`build "${input.slug}" already exists`)
|
|
406
|
+
}
|
|
407
|
+
this.db
|
|
408
|
+
.insert(builds)
|
|
409
|
+
.values({
|
|
410
|
+
slug: input.slug,
|
|
411
|
+
repo: input.repo,
|
|
412
|
+
repoOrigin: input.repoOrigin ?? null,
|
|
413
|
+
ticket: input.ticket ?? null,
|
|
414
|
+
branch: input.branch ?? null,
|
|
415
|
+
createdAt: ts,
|
|
416
|
+
updatedAt: ts,
|
|
417
|
+
})
|
|
418
|
+
.run()
|
|
419
|
+
return this.toRecord(this.requireBuild(input.slug))
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async getBuild(slug: string): Promise<BuildRecord | null> {
|
|
424
|
+
const row = this.buildRow(slug)
|
|
425
|
+
return row ? this.toRecord(row) : null
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async listBuilds(): Promise<BuildRecord[]> {
|
|
429
|
+
const rows = this.db.select().from(builds).orderBy(asc(builds.createdAt)).all()
|
|
430
|
+
return rows.map((row) => this.toRecord(row))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Current build-stream tail inside an open transaction. */
|
|
434
|
+
private currentSeqInTx(slug: string): number {
|
|
435
|
+
this.requireBuild(slug)
|
|
436
|
+
const row = this.db
|
|
437
|
+
.select({ max: sql<number | null>`max(${events.seq})` })
|
|
438
|
+
.from(events)
|
|
439
|
+
.where(eq(events.build, slug))
|
|
440
|
+
.get()
|
|
441
|
+
return row?.max ?? 0
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Runs inside an open transaction. bun:sqlite is a single synchronous
|
|
446
|
+
* connection, so statements issued through `this.db` inside a
|
|
447
|
+
* `db.transaction` callback join that transaction.
|
|
448
|
+
*/
|
|
449
|
+
private appendInTx(slug: string, validated: EventWrite): EventEnvelope {
|
|
450
|
+
const seq = this.currentSeqInTx(slug) + 1
|
|
451
|
+
const ts = this.now()
|
|
452
|
+
this.db
|
|
453
|
+
.insert(events)
|
|
454
|
+
.values({
|
|
455
|
+
build: slug,
|
|
456
|
+
seq,
|
|
457
|
+
ts,
|
|
458
|
+
actor: validated.actor,
|
|
459
|
+
type: validated.type,
|
|
460
|
+
payload: validated.payload,
|
|
461
|
+
})
|
|
462
|
+
.run()
|
|
463
|
+
this.db.update(builds).set({ updatedAt: ts }).where(eq(builds.slug, slug)).run()
|
|
464
|
+
return {
|
|
465
|
+
build: slug,
|
|
466
|
+
seq,
|
|
467
|
+
ts,
|
|
468
|
+
actor: validated.actor,
|
|
469
|
+
type: validated.type,
|
|
470
|
+
payload: validated.payload,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
|
|
475
|
+
const validated = validateEventWrite(event)
|
|
476
|
+
return this.writeTx(() => this.appendInTx(slug, validated)) as EventEnvelope<T>
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async appendIfCurrent<T extends EventType>(
|
|
480
|
+
slug: string,
|
|
481
|
+
expectedSeq: number,
|
|
482
|
+
event: EventWrite<T>,
|
|
483
|
+
): Promise<EventEnvelope<T> | null> {
|
|
484
|
+
validateExpectedSeq(expectedSeq)
|
|
485
|
+
const validated = validateEventWrite(event)
|
|
486
|
+
return this.writeTx(() => {
|
|
487
|
+
// BEGIN IMMEDIATE serializes this comparison and append across every
|
|
488
|
+
// SQLite connection/process using the same store file.
|
|
489
|
+
if (this.currentSeqInTx(slug) !== expectedSeq) return null
|
|
490
|
+
return this.appendInTx(slug, validated) as EventEnvelope<T>
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Hash + blob write happen before any transaction (D6). */
|
|
495
|
+
private async prepareArtifact(artifact: ArtifactInput): Promise<PreparedArtifact> {
|
|
496
|
+
if (!artifact.kind) throw new Error('artifact kind is required')
|
|
497
|
+
const bytes = toBytes(artifact.content)
|
|
498
|
+
const blobRef = contentHash(bytes)
|
|
499
|
+
await this.blobs.put(blobRef, bytes)
|
|
500
|
+
return {
|
|
501
|
+
kind: artifact.kind,
|
|
502
|
+
blobRef,
|
|
503
|
+
metadata: structuredClone(artifact.metadata ?? {}),
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Runs inside an open transaction — see `appendInTx`.
|
|
509
|
+
*
|
|
510
|
+
* `prune: false` defers deposit-time retention to the batch caller (the
|
|
511
|
+
* atomic `appendWithArtifacts` path), which prunes once per distinct kind
|
|
512
|
+
* *after* the batch event is validated — see the invariant documented on
|
|
513
|
+
* `appendWithArtifacts`. Single-deposit paths keep the default
|
|
514
|
+
* `prune: true` (deposit and prune in one step; no event is appended, so
|
|
515
|
+
* the validation-before-prune invariant does not apply).
|
|
516
|
+
*/
|
|
517
|
+
private depositInTx(
|
|
518
|
+
slug: string,
|
|
519
|
+
prepared: PreparedArtifact,
|
|
520
|
+
opts: { prune?: boolean } = { prune: true },
|
|
521
|
+
): ArtifactMeta {
|
|
522
|
+
this.requireBuild(slug)
|
|
523
|
+
const createdAt = this.now()
|
|
524
|
+
const row = this.db
|
|
525
|
+
.select({ max: sql<number | null>`max(${artifacts.revision})` })
|
|
526
|
+
.from(artifacts)
|
|
527
|
+
.where(and(eq(artifacts.build, slug), eq(artifacts.kind, prepared.kind)))
|
|
528
|
+
.get()
|
|
529
|
+
const revision = (row?.max ?? -1) + 1
|
|
530
|
+
this.db
|
|
531
|
+
.insert(artifacts)
|
|
532
|
+
.values({
|
|
533
|
+
build: slug,
|
|
534
|
+
kind: prepared.kind,
|
|
535
|
+
revision,
|
|
536
|
+
blobRef: prepared.blobRef,
|
|
537
|
+
metadata: prepared.metadata,
|
|
538
|
+
createdAt,
|
|
539
|
+
})
|
|
540
|
+
.run()
|
|
541
|
+
if (opts.prune) this.pruneBuildInTx(slug, prepared.kind)
|
|
542
|
+
this.db.update(builds).set({ updatedAt: createdAt }).where(eq(builds.slug, slug)).run()
|
|
543
|
+
return {
|
|
544
|
+
build: slug,
|
|
545
|
+
kind: prepared.kind,
|
|
546
|
+
revision,
|
|
547
|
+
blobRef: prepared.blobRef,
|
|
548
|
+
metadata: prepared.metadata,
|
|
549
|
+
createdAt,
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Deposit-time retention (store/retention.ts): inside the same transaction
|
|
555
|
+
* as the deposit, drop revisions past the bound for retention-managed
|
|
556
|
+
* kinds. Revision assignment reads MAX(revision)+1, so pruning never
|
|
557
|
+
* collides with it. Non-family kinds are never touched.
|
|
558
|
+
*/
|
|
559
|
+
private pruneBuildInTx(slug: string, kind: string): void {
|
|
560
|
+
if (!isRetentionManagedKind(kind)) return
|
|
561
|
+
const rows = this.db
|
|
562
|
+
.select({ revision: artifacts.revision })
|
|
563
|
+
.from(artifacts)
|
|
564
|
+
.where(and(eq(artifacts.build, slug), eq(artifacts.kind, kind)))
|
|
565
|
+
.all()
|
|
566
|
+
const pruned = revisionsToPrune(
|
|
567
|
+
rows.map((row) => row.revision),
|
|
568
|
+
this.maxRevisions,
|
|
569
|
+
)
|
|
570
|
+
if (pruned.length === 0) return
|
|
571
|
+
this.db
|
|
572
|
+
.delete(artifacts)
|
|
573
|
+
.where(
|
|
574
|
+
and(
|
|
575
|
+
eq(artifacts.build, slug),
|
|
576
|
+
eq(artifacts.kind, kind),
|
|
577
|
+
inArray(artifacts.revision, pruned),
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
.run()
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private pruneRepoInTx(repo: string, kind: string): void {
|
|
584
|
+
if (!isRetentionManagedKind(kind)) return
|
|
585
|
+
const rows = this.db
|
|
586
|
+
.select({ revision: repoArtifacts.revision })
|
|
587
|
+
.from(repoArtifacts)
|
|
588
|
+
.where(and(eq(repoArtifacts.repo, repo), eq(repoArtifacts.kind, kind)))
|
|
589
|
+
.all()
|
|
590
|
+
const pruned = revisionsToPrune(
|
|
591
|
+
rows.map((row) => row.revision),
|
|
592
|
+
this.maxRevisions,
|
|
593
|
+
)
|
|
594
|
+
if (pruned.length === 0) return
|
|
595
|
+
this.db
|
|
596
|
+
.delete(repoArtifacts)
|
|
597
|
+
.where(
|
|
598
|
+
and(
|
|
599
|
+
eq(repoArtifacts.repo, repo),
|
|
600
|
+
eq(repoArtifacts.kind, kind),
|
|
601
|
+
inArray(repoArtifacts.revision, pruned),
|
|
602
|
+
),
|
|
603
|
+
)
|
|
604
|
+
.run()
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async appendWithArtifacts<T extends EventType>(
|
|
608
|
+
slug: string,
|
|
609
|
+
artifactInputs: ArtifactInput[],
|
|
610
|
+
makeEvent: (deposited: ArtifactMeta[]) => EventWrite<T>,
|
|
611
|
+
): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
|
|
612
|
+
const prepared: PreparedArtifact[] = []
|
|
613
|
+
for (const input of artifactInputs) {
|
|
614
|
+
prepared.push(await this.prepareArtifact(input))
|
|
615
|
+
}
|
|
616
|
+
// One synchronous transaction: deposits + event append commit together;
|
|
617
|
+
// an invalid event throws, rolling back every deposit (D6).
|
|
618
|
+
//
|
|
619
|
+
// Ordering invariant (AUT-322): the batch event is validated BEFORE any
|
|
620
|
+
// retention prune runs. Deposits land unpruned, validation gates the
|
|
621
|
+
// whole batch, and only then does one prune per distinct batch kind
|
|
622
|
+
// execute — still inside this transaction. A same-kind batch whose prune
|
|
623
|
+
// scope covers a sibling therefore never deletes that sibling before the
|
|
624
|
+
// batch's event is validated. Pruning once per kind (not per deposit) is
|
|
625
|
+
// equivalent: `revisionsToPrune` is a pure function of the full
|
|
626
|
+
// post-batch revision set, and pruned revisions are always older than
|
|
627
|
+
// the current MAX, so revision assignment is unaffected.
|
|
628
|
+
return this.writeTx(() => {
|
|
629
|
+
const deposited = prepared.map((p) => this.depositInTx(slug, p, { prune: false }))
|
|
630
|
+
const validated = validateEventWrite(makeEvent(deposited))
|
|
631
|
+
for (const kind of new Set(deposited.map((meta) => meta.kind))) {
|
|
632
|
+
this.pruneBuildInTx(slug, kind)
|
|
633
|
+
}
|
|
634
|
+
const event = this.appendInTx(slug, validated) as EventEnvelope<T>
|
|
635
|
+
return { event, artifacts: deposited }
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async getEvents(
|
|
640
|
+
slug: string,
|
|
641
|
+
sinceSeq = 0,
|
|
642
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
643
|
+
): Promise<AbEvent[]> {
|
|
644
|
+
return readEventsWithWait({
|
|
645
|
+
read: async () => {
|
|
646
|
+
this.requireBuild(slug)
|
|
647
|
+
const rows = this.db
|
|
648
|
+
.select()
|
|
649
|
+
.from(events)
|
|
650
|
+
.where(and(eq(events.build, slug), gt(events.seq, sinceSeq)))
|
|
651
|
+
.orderBy(asc(events.seq))
|
|
652
|
+
.all()
|
|
653
|
+
return rows.map(
|
|
654
|
+
(row) =>
|
|
655
|
+
({
|
|
656
|
+
build: row.build,
|
|
657
|
+
seq: row.seq,
|
|
658
|
+
ts: row.ts,
|
|
659
|
+
actor: row.actor,
|
|
660
|
+
type: row.type,
|
|
661
|
+
payload: row.payload,
|
|
662
|
+
}) as AbEvent,
|
|
663
|
+
)
|
|
664
|
+
},
|
|
665
|
+
waitSeconds: opts?.waitSeconds,
|
|
666
|
+
signal: opts?.signal,
|
|
667
|
+
})
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async putArtifact(slug: string, artifact: ArtifactInput): Promise<ArtifactMeta> {
|
|
671
|
+
const prepared = await this.prepareArtifact(artifact)
|
|
672
|
+
return this.writeTx(() => this.depositInTx(slug, prepared))
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private toMeta(row: typeof artifacts.$inferSelect): ArtifactMeta {
|
|
676
|
+
return {
|
|
677
|
+
build: row.build,
|
|
678
|
+
kind: row.kind,
|
|
679
|
+
revision: row.revision,
|
|
680
|
+
blobRef: row.blobRef,
|
|
681
|
+
metadata: row.metadata,
|
|
682
|
+
createdAt: row.createdAt,
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async getArtifact(slug: string, kind: string, rev?: number): Promise<Artifact | null> {
|
|
687
|
+
this.requireBuild(slug)
|
|
688
|
+
const scoped = and(eq(artifacts.build, slug), eq(artifacts.kind, kind))
|
|
689
|
+
const row =
|
|
690
|
+
rev === undefined
|
|
691
|
+
? this.db
|
|
692
|
+
.select()
|
|
693
|
+
.from(artifacts)
|
|
694
|
+
.where(scoped)
|
|
695
|
+
.orderBy(desc(artifacts.revision))
|
|
696
|
+
.limit(1)
|
|
697
|
+
.get()
|
|
698
|
+
: this.db
|
|
699
|
+
.select()
|
|
700
|
+
.from(artifacts)
|
|
701
|
+
.where(and(scoped, eq(artifacts.revision, rev)))
|
|
702
|
+
.get()
|
|
703
|
+
if (!row) return null
|
|
704
|
+
const content = await this.blobs.get(row.blobRef)
|
|
705
|
+
if (!content) return null
|
|
706
|
+
return { meta: this.toMeta(row), content }
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async listArtifacts(slug: string, kind?: string): Promise<ArtifactMeta[]> {
|
|
710
|
+
this.requireBuild(slug)
|
|
711
|
+
const where = kind
|
|
712
|
+
? and(eq(artifacts.build, slug), eq(artifacts.kind, kind))
|
|
713
|
+
: eq(artifacts.build, slug)
|
|
714
|
+
const rows = this.db
|
|
715
|
+
.select()
|
|
716
|
+
.from(artifacts)
|
|
717
|
+
.where(where)
|
|
718
|
+
.orderBy(asc(artifacts.kind), asc(artifacts.revision))
|
|
719
|
+
.all()
|
|
720
|
+
return rows.map((row) => this.toMeta(row))
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
724
|
+
return this.writeTx(() => {
|
|
725
|
+
const row = this.requireBuild(slug)
|
|
726
|
+
const now = this.clock().getTime()
|
|
727
|
+
const heldByOther =
|
|
728
|
+
row.leaseHolder !== null &&
|
|
729
|
+
row.leaseHolder !== holder &&
|
|
730
|
+
row.leaseExpiresAt !== null &&
|
|
731
|
+
Date.parse(row.leaseExpiresAt) > now
|
|
732
|
+
if (heldByOther) return false
|
|
733
|
+
this.db
|
|
734
|
+
.update(builds)
|
|
735
|
+
.set({
|
|
736
|
+
leaseHolder: holder,
|
|
737
|
+
leaseExpiresAt: new Date(now + ttlMs).toISOString(),
|
|
738
|
+
leaseTtlMs: ttlMs,
|
|
739
|
+
updatedAt: new Date(now).toISOString(),
|
|
740
|
+
})
|
|
741
|
+
.where(eq(builds.slug, slug))
|
|
742
|
+
.run()
|
|
743
|
+
return true
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async heartbeat(slug: string, holder: string): Promise<boolean> {
|
|
748
|
+
return this.writeTx(() => {
|
|
749
|
+
const row = this.requireBuild(slug)
|
|
750
|
+
const now = this.clock().getTime()
|
|
751
|
+
const holds =
|
|
752
|
+
row.leaseHolder === holder &&
|
|
753
|
+
row.leaseExpiresAt !== null &&
|
|
754
|
+
Date.parse(row.leaseExpiresAt) > now
|
|
755
|
+
if (!holds) return false
|
|
756
|
+
const nowIso = new Date(now).toISOString()
|
|
757
|
+
this.db
|
|
758
|
+
.update(builds)
|
|
759
|
+
.set({
|
|
760
|
+
leaseExpiresAt: new Date(now + (row.leaseTtlMs ?? 0)).toISOString(),
|
|
761
|
+
heartbeatAt: nowIso,
|
|
762
|
+
updatedAt: nowIso,
|
|
763
|
+
})
|
|
764
|
+
.where(eq(builds.slug, slug))
|
|
765
|
+
.run()
|
|
766
|
+
return true
|
|
767
|
+
})
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async releaseLease(slug: string, holder: string): Promise<void> {
|
|
771
|
+
this.writeTx(() => {
|
|
772
|
+
const row = this.requireBuild(slug)
|
|
773
|
+
if (row.leaseHolder !== holder) return
|
|
774
|
+
this.db
|
|
775
|
+
.update(builds)
|
|
776
|
+
.set({
|
|
777
|
+
leaseHolder: null,
|
|
778
|
+
leaseExpiresAt: null,
|
|
779
|
+
leaseTtlMs: null,
|
|
780
|
+
updatedAt: this.now(),
|
|
781
|
+
})
|
|
782
|
+
.where(eq(builds.slug, slug))
|
|
783
|
+
.run()
|
|
784
|
+
})
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async ensureRepo(repo: string): Promise<RepositoryRecord> {
|
|
788
|
+
if (!repo) throw new Error('repo is required')
|
|
789
|
+
return this.writeTx(() => {
|
|
790
|
+
const existing = this.repoRow(repo)
|
|
791
|
+
if (existing) return this.toRepoRecord(existing)
|
|
792
|
+
const ts = this.now()
|
|
793
|
+
this.db.insert(repoStreams).values({ repo, createdAt: ts, updatedAt: ts }).run()
|
|
794
|
+
return this.toRepoRecord(this.requireRepo(repo))
|
|
795
|
+
})
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async getRepo(repo: string): Promise<RepositoryRecord | null> {
|
|
799
|
+
const row = this.repoRow(repo)
|
|
800
|
+
return row ? this.toRepoRecord(row) : null
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private appendRepoInTx(repo: string, validated: RepositoryEventWrite): RepositoryEventEnvelope {
|
|
804
|
+
this.requireRepo(repo)
|
|
805
|
+
const ts = this.now()
|
|
806
|
+
const row = this.db
|
|
807
|
+
.select({ max: sql<number | null>`max(${repoEvents.seq})` })
|
|
808
|
+
.from(repoEvents)
|
|
809
|
+
.where(eq(repoEvents.repo, repo))
|
|
810
|
+
.get()
|
|
811
|
+
const seq = (row?.max ?? 0) + 1
|
|
812
|
+
this.db
|
|
813
|
+
.insert(repoEvents)
|
|
814
|
+
.values({
|
|
815
|
+
repo,
|
|
816
|
+
seq,
|
|
817
|
+
ts,
|
|
818
|
+
actor: validated.actor,
|
|
819
|
+
type: validated.type,
|
|
820
|
+
payload: validated.payload,
|
|
821
|
+
})
|
|
822
|
+
.run()
|
|
823
|
+
this.db.update(repoStreams).set({ updatedAt: ts }).where(eq(repoStreams.repo, repo)).run()
|
|
824
|
+
return {
|
|
825
|
+
repo,
|
|
826
|
+
seq,
|
|
827
|
+
ts,
|
|
828
|
+
actor: validated.actor,
|
|
829
|
+
type: validated.type,
|
|
830
|
+
payload: validated.payload,
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async appendRepo<T extends RepositoryEventType>(
|
|
835
|
+
repo: string,
|
|
836
|
+
event: RepositoryEventWrite<T>,
|
|
837
|
+
): Promise<RepositoryEventEnvelope<T>> {
|
|
838
|
+
const validated = validateRepositoryEventWrite(event)
|
|
839
|
+
return this.writeTx(() => this.appendRepoInTx(repo, validated)) as RepositoryEventEnvelope<T>
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** See `depositInTx` for the `prune` option's meaning. */
|
|
843
|
+
private depositRepoInTx(
|
|
844
|
+
repo: string,
|
|
845
|
+
prepared: PreparedArtifact,
|
|
846
|
+
opts: { prune?: boolean } = { prune: true },
|
|
847
|
+
): RepositoryArtifactMeta {
|
|
848
|
+
this.requireRepo(repo)
|
|
849
|
+
const createdAt = this.now()
|
|
850
|
+
const row = this.db
|
|
851
|
+
.select({ max: sql<number | null>`max(${repoArtifacts.revision})` })
|
|
852
|
+
.from(repoArtifacts)
|
|
853
|
+
.where(and(eq(repoArtifacts.repo, repo), eq(repoArtifacts.kind, prepared.kind)))
|
|
854
|
+
.get()
|
|
855
|
+
const revision = (row?.max ?? -1) + 1
|
|
856
|
+
this.db
|
|
857
|
+
.insert(repoArtifacts)
|
|
858
|
+
.values({
|
|
859
|
+
repo,
|
|
860
|
+
kind: prepared.kind,
|
|
861
|
+
revision,
|
|
862
|
+
blobRef: prepared.blobRef,
|
|
863
|
+
metadata: prepared.metadata,
|
|
864
|
+
createdAt,
|
|
865
|
+
})
|
|
866
|
+
.run()
|
|
867
|
+
if (opts.prune) this.pruneRepoInTx(repo, prepared.kind)
|
|
868
|
+
this.db
|
|
869
|
+
.update(repoStreams)
|
|
870
|
+
.set({ updatedAt: createdAt })
|
|
871
|
+
.where(eq(repoStreams.repo, repo))
|
|
872
|
+
.run()
|
|
873
|
+
return {
|
|
874
|
+
repo,
|
|
875
|
+
kind: prepared.kind,
|
|
876
|
+
revision,
|
|
877
|
+
blobRef: prepared.blobRef,
|
|
878
|
+
metadata: prepared.metadata,
|
|
879
|
+
createdAt,
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async appendRepoWithArtifacts<T extends RepositoryEventType>(
|
|
884
|
+
repo: string,
|
|
885
|
+
artifactInputs: ArtifactInput[],
|
|
886
|
+
makeEvent: (deposited: RepositoryArtifactMeta[]) => RepositoryEventWrite<T>,
|
|
887
|
+
): Promise<{
|
|
888
|
+
event: RepositoryEventEnvelope<T>
|
|
889
|
+
artifacts: RepositoryArtifactMeta[]
|
|
890
|
+
}> {
|
|
891
|
+
const prepared: PreparedArtifact[] = []
|
|
892
|
+
for (const input of artifactInputs) {
|
|
893
|
+
prepared.push(await this.prepareArtifact(input))
|
|
894
|
+
}
|
|
895
|
+
// Ordering invariant (AUT-322): same shape as `appendWithArtifacts` —
|
|
896
|
+
// deposit unpruned, validate the batch event, then prune once per
|
|
897
|
+
// distinct batch kind, all inside this transaction. Validation
|
|
898
|
+
// provably precedes any retention deletion.
|
|
899
|
+
return this.writeTx(() => {
|
|
900
|
+
const deposited = prepared.map((item) => this.depositRepoInTx(repo, item, { prune: false }))
|
|
901
|
+
const validated = validateRepositoryEventWrite(makeEvent(deposited))
|
|
902
|
+
for (const kind of new Set(deposited.map((meta) => meta.kind))) {
|
|
903
|
+
this.pruneRepoInTx(repo, kind)
|
|
904
|
+
}
|
|
905
|
+
const event = this.appendRepoInTx(repo, validated) as RepositoryEventEnvelope<T>
|
|
906
|
+
return { event, artifacts: deposited }
|
|
907
|
+
})
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async getRepoEvents(
|
|
911
|
+
repo: string,
|
|
912
|
+
sinceSeq = 0,
|
|
913
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
914
|
+
): Promise<RepositoryEvent[]> {
|
|
915
|
+
return readEventsWithWait({
|
|
916
|
+
read: async () => {
|
|
917
|
+
this.requireRepo(repo)
|
|
918
|
+
const rows = this.db
|
|
919
|
+
.select()
|
|
920
|
+
.from(repoEvents)
|
|
921
|
+
.where(and(eq(repoEvents.repo, repo), gt(repoEvents.seq, sinceSeq)))
|
|
922
|
+
.orderBy(asc(repoEvents.seq))
|
|
923
|
+
.all()
|
|
924
|
+
return rows.map(
|
|
925
|
+
(row) =>
|
|
926
|
+
({
|
|
927
|
+
repo: row.repo,
|
|
928
|
+
seq: row.seq,
|
|
929
|
+
ts: row.ts,
|
|
930
|
+
actor: row.actor,
|
|
931
|
+
type: row.type,
|
|
932
|
+
payload: row.payload,
|
|
933
|
+
}) as RepositoryEvent,
|
|
934
|
+
)
|
|
935
|
+
},
|
|
936
|
+
waitSeconds: opts?.waitSeconds,
|
|
937
|
+
signal: opts?.signal,
|
|
938
|
+
})
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
|
|
942
|
+
const prepared = await this.prepareArtifact(artifact)
|
|
943
|
+
return this.writeTx(() => this.depositRepoInTx(repo, prepared))
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private toRepoMeta(row: typeof repoArtifacts.$inferSelect): RepositoryArtifactMeta {
|
|
947
|
+
return {
|
|
948
|
+
repo: row.repo,
|
|
949
|
+
kind: row.kind,
|
|
950
|
+
revision: row.revision,
|
|
951
|
+
blobRef: row.blobRef,
|
|
952
|
+
metadata: row.metadata,
|
|
953
|
+
createdAt: row.createdAt,
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async getRepoArtifact(
|
|
958
|
+
repo: string,
|
|
959
|
+
kind: string,
|
|
960
|
+
rev?: number,
|
|
961
|
+
): Promise<RepositoryArtifact | null> {
|
|
962
|
+
this.requireRepo(repo)
|
|
963
|
+
const scoped = and(eq(repoArtifacts.repo, repo), eq(repoArtifacts.kind, kind))
|
|
964
|
+
const row =
|
|
965
|
+
rev === undefined
|
|
966
|
+
? this.db
|
|
967
|
+
.select()
|
|
968
|
+
.from(repoArtifacts)
|
|
969
|
+
.where(scoped)
|
|
970
|
+
.orderBy(desc(repoArtifacts.revision))
|
|
971
|
+
.limit(1)
|
|
972
|
+
.get()
|
|
973
|
+
: this.db
|
|
974
|
+
.select()
|
|
975
|
+
.from(repoArtifacts)
|
|
976
|
+
.where(and(scoped, eq(repoArtifacts.revision, rev)))
|
|
977
|
+
.get()
|
|
978
|
+
if (!row) return null
|
|
979
|
+
const content = await this.blobs.get(row.blobRef)
|
|
980
|
+
return content ? { meta: this.toRepoMeta(row), content } : null
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]> {
|
|
984
|
+
this.requireRepo(repo)
|
|
985
|
+
const where = kind
|
|
986
|
+
? and(eq(repoArtifacts.repo, repo), eq(repoArtifacts.kind, kind))
|
|
987
|
+
: eq(repoArtifacts.repo, repo)
|
|
988
|
+
return this.db
|
|
989
|
+
.select()
|
|
990
|
+
.from(repoArtifacts)
|
|
991
|
+
.where(where)
|
|
992
|
+
.orderBy(asc(repoArtifacts.kind), asc(repoArtifacts.revision))
|
|
993
|
+
.all()
|
|
994
|
+
.map((row) => this.toRepoMeta(row))
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
998
|
+
return this.writeTx(() => {
|
|
999
|
+
const row = this.requireRepo(repo)
|
|
1000
|
+
const now = this.clock().getTime()
|
|
1001
|
+
const heldByOther =
|
|
1002
|
+
row.leaseHolder !== null &&
|
|
1003
|
+
row.leaseHolder !== holder &&
|
|
1004
|
+
row.leaseExpiresAt !== null &&
|
|
1005
|
+
Date.parse(row.leaseExpiresAt) > now
|
|
1006
|
+
if (heldByOther) return false
|
|
1007
|
+
this.db
|
|
1008
|
+
.update(repoStreams)
|
|
1009
|
+
.set({
|
|
1010
|
+
leaseHolder: holder,
|
|
1011
|
+
leaseExpiresAt: new Date(now + ttlMs).toISOString(),
|
|
1012
|
+
leaseTtlMs: ttlMs,
|
|
1013
|
+
updatedAt: new Date(now).toISOString(),
|
|
1014
|
+
})
|
|
1015
|
+
.where(eq(repoStreams.repo, repo))
|
|
1016
|
+
.run()
|
|
1017
|
+
return true
|
|
1018
|
+
})
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async heartbeatRepo(repo: string, holder: string): Promise<boolean> {
|
|
1022
|
+
return this.writeTx(() => {
|
|
1023
|
+
const row = this.requireRepo(repo)
|
|
1024
|
+
const now = this.clock().getTime()
|
|
1025
|
+
const holds =
|
|
1026
|
+
row.leaseHolder === holder &&
|
|
1027
|
+
row.leaseExpiresAt !== null &&
|
|
1028
|
+
Date.parse(row.leaseExpiresAt) > now
|
|
1029
|
+
if (!holds) return false
|
|
1030
|
+
const nowIso = new Date(now).toISOString()
|
|
1031
|
+
this.db
|
|
1032
|
+
.update(repoStreams)
|
|
1033
|
+
.set({
|
|
1034
|
+
leaseExpiresAt: new Date(now + (row.leaseTtlMs ?? 0)).toISOString(),
|
|
1035
|
+
heartbeatAt: nowIso,
|
|
1036
|
+
updatedAt: nowIso,
|
|
1037
|
+
})
|
|
1038
|
+
.where(eq(repoStreams.repo, repo))
|
|
1039
|
+
.run()
|
|
1040
|
+
return true
|
|
1041
|
+
})
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async releaseRepoLease(repo: string, holder: string): Promise<void> {
|
|
1045
|
+
this.writeTx(() => {
|
|
1046
|
+
const row = this.requireRepo(repo)
|
|
1047
|
+
if (row.leaseHolder !== holder) return
|
|
1048
|
+
this.db
|
|
1049
|
+
.update(repoStreams)
|
|
1050
|
+
.set({
|
|
1051
|
+
leaseHolder: null,
|
|
1052
|
+
leaseExpiresAt: null,
|
|
1053
|
+
leaseTtlMs: null,
|
|
1054
|
+
updatedAt: this.now(),
|
|
1055
|
+
})
|
|
1056
|
+
.where(eq(repoStreams.repo, repo))
|
|
1057
|
+
.run()
|
|
1058
|
+
})
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ── Operator sessions (SPEC §7.1.1 — a third resource kind) ─────────
|
|
1062
|
+
|
|
1063
|
+
private sessionRow(id: string): typeof sessions.$inferSelect | undefined {
|
|
1064
|
+
return this.db.select().from(sessions).where(eq(sessions.id, id)).get()
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
private requireSession(id: string): typeof sessions.$inferSelect {
|
|
1068
|
+
const row = this.sessionRow(id)
|
|
1069
|
+
if (!row) throw new Error(`unknown session "${id}"`)
|
|
1070
|
+
return row
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
private toSessionRecord(row: typeof sessions.$inferSelect): SessionRecord {
|
|
1074
|
+
return {
|
|
1075
|
+
id: row.id,
|
|
1076
|
+
repo: row.repo,
|
|
1077
|
+
operator: row.operator,
|
|
1078
|
+
...(row.title !== null ? { title: row.title } : {}),
|
|
1079
|
+
createdAt: row.createdAt,
|
|
1080
|
+
updatedAt: row.updatedAt,
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Runs inside an open transaction — see `appendInTx`. */
|
|
1085
|
+
private appendSessionInTx(id: string, validated: SessionEventWrite): SessionEventEnvelope {
|
|
1086
|
+
this.requireSession(id)
|
|
1087
|
+
const ts = this.now()
|
|
1088
|
+
const row = this.db
|
|
1089
|
+
.select({ max: sql<number | null>`max(${sessionEvents.seq})` })
|
|
1090
|
+
.from(sessionEvents)
|
|
1091
|
+
.where(eq(sessionEvents.session, id))
|
|
1092
|
+
.get()
|
|
1093
|
+
const seq = (row?.max ?? 0) + 1
|
|
1094
|
+
this.db
|
|
1095
|
+
.insert(sessionEvents)
|
|
1096
|
+
.values({
|
|
1097
|
+
session: id,
|
|
1098
|
+
seq,
|
|
1099
|
+
ts,
|
|
1100
|
+
actor: validated.actor,
|
|
1101
|
+
type: validated.type,
|
|
1102
|
+
payload: validated.payload,
|
|
1103
|
+
})
|
|
1104
|
+
.run()
|
|
1105
|
+
this.db.update(sessions).set({ updatedAt: ts }).where(eq(sessions.id, id)).run()
|
|
1106
|
+
return {
|
|
1107
|
+
session: id,
|
|
1108
|
+
seq,
|
|
1109
|
+
ts,
|
|
1110
|
+
actor: validated.actor,
|
|
1111
|
+
type: validated.type,
|
|
1112
|
+
payload: validated.payload,
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async createSession(input: NewSessionInput): Promise<SessionRecord> {
|
|
1117
|
+
const operator = normalizeOperator(input.operator)
|
|
1118
|
+
if (!input.repo) throw new Error('repo is required')
|
|
1119
|
+
const ts = this.now()
|
|
1120
|
+
const id = `os_${crypto.randomUUID()}`
|
|
1121
|
+
// The record and its first fact land together: `session.created` (seq 1,
|
|
1122
|
+
// actor the operator) commits in the same transaction as the insert —
|
|
1123
|
+
// there is no state where a session exists without its creation fact (D6).
|
|
1124
|
+
const validated = validateSessionEventWrite({
|
|
1125
|
+
actor: humanActor(operator),
|
|
1126
|
+
type: 'session.created',
|
|
1127
|
+
payload: input.title !== undefined ? { title: input.title } : {},
|
|
1128
|
+
})
|
|
1129
|
+
return this.writeTx(() => {
|
|
1130
|
+
// Store-assigned monotonic creation sequence: MAX+1 inside the write
|
|
1131
|
+
// transaction (every write runs BEGIN IMMEDIATE, so writers are
|
|
1132
|
+
// serialized) — the same pattern as the seq assignments in this file.
|
|
1133
|
+
// Sessions are never deleted, so the counter is never reused and the
|
|
1134
|
+
// listSessions same-millisecond tiebreak is stable over time.
|
|
1135
|
+
const tail = this.db
|
|
1136
|
+
.select({ max: sql<number | null>`max(${sessions.creationSeq})` })
|
|
1137
|
+
.from(sessions)
|
|
1138
|
+
.get()
|
|
1139
|
+
const creationSeq = (tail?.max ?? 0) + 1
|
|
1140
|
+
this.db
|
|
1141
|
+
.insert(sessions)
|
|
1142
|
+
.values({
|
|
1143
|
+
id,
|
|
1144
|
+
repo: input.repo,
|
|
1145
|
+
operator,
|
|
1146
|
+
title: input.title ?? null,
|
|
1147
|
+
creationSeq,
|
|
1148
|
+
createdAt: ts,
|
|
1149
|
+
updatedAt: ts,
|
|
1150
|
+
})
|
|
1151
|
+
.run()
|
|
1152
|
+
this.db
|
|
1153
|
+
.insert(sessionEvents)
|
|
1154
|
+
.values({
|
|
1155
|
+
session: id,
|
|
1156
|
+
seq: 1,
|
|
1157
|
+
ts,
|
|
1158
|
+
actor: validated.actor,
|
|
1159
|
+
type: validated.type,
|
|
1160
|
+
payload: validated.payload,
|
|
1161
|
+
})
|
|
1162
|
+
.run()
|
|
1163
|
+
return this.toSessionRecord(this.requireSession(id))
|
|
1164
|
+
})
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async getSession(id: string): Promise<SessionRecord | null> {
|
|
1168
|
+
const row = this.sessionRow(id)
|
|
1169
|
+
return row ? this.toSessionRecord(row) : null
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
async listSessions(repo: string): Promise<SessionRecord[]> {
|
|
1173
|
+
// Pinned tiebreak (store/types.ts): createdAt ascending, then the
|
|
1174
|
+
// store-assigned monotonic creation sequence — never the random id.
|
|
1175
|
+
return this.db
|
|
1176
|
+
.select()
|
|
1177
|
+
.from(sessions)
|
|
1178
|
+
.where(eq(sessions.repo, repo))
|
|
1179
|
+
.orderBy(asc(sessions.createdAt), asc(sessions.creationSeq))
|
|
1180
|
+
.all()
|
|
1181
|
+
.map((row) => this.toSessionRecord(row))
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async appendSessionEvent<T extends SessionEventType>(
|
|
1185
|
+
id: string,
|
|
1186
|
+
event: SessionEventWrite<T>,
|
|
1187
|
+
): Promise<SessionEventEnvelope<T>> {
|
|
1188
|
+
const validated = validateSessionEventWrite(event)
|
|
1189
|
+
return this.writeTx(() => this.appendSessionInTx(id, validated) as SessionEventEnvelope<T>)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async getSessionEvents(
|
|
1193
|
+
id: string,
|
|
1194
|
+
sinceSeq = 0,
|
|
1195
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
1196
|
+
): Promise<SessionEvent[]> {
|
|
1197
|
+
const read = async (): Promise<SessionEvent[]> => {
|
|
1198
|
+
this.requireSession(id)
|
|
1199
|
+
const rows = this.db
|
|
1200
|
+
.select()
|
|
1201
|
+
.from(sessionEvents)
|
|
1202
|
+
.where(and(eq(sessionEvents.session, id), gt(sessionEvents.seq, sinceSeq)))
|
|
1203
|
+
.orderBy(asc(sessionEvents.seq))
|
|
1204
|
+
.all()
|
|
1205
|
+
return rows.map(
|
|
1206
|
+
(row) =>
|
|
1207
|
+
({
|
|
1208
|
+
session: row.session,
|
|
1209
|
+
seq: row.seq,
|
|
1210
|
+
ts: row.ts,
|
|
1211
|
+
actor: row.actor,
|
|
1212
|
+
type: row.type,
|
|
1213
|
+
payload: row.payload,
|
|
1214
|
+
}) as SessionEvent,
|
|
1215
|
+
)
|
|
1216
|
+
}
|
|
1217
|
+
return readEventsWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Runs inside an open transaction — see `depositInTx` for `prune`. */
|
|
1221
|
+
private depositSessionInTx(id: string, prepared: PreparedArtifact): SessionArtifactMeta {
|
|
1222
|
+
this.requireSession(id)
|
|
1223
|
+
const createdAt = this.now()
|
|
1224
|
+
const row = this.db
|
|
1225
|
+
.select({ max: sql<number | null>`max(${sessionArtifacts.revision})` })
|
|
1226
|
+
.from(sessionArtifacts)
|
|
1227
|
+
.where(and(eq(sessionArtifacts.session, id), eq(sessionArtifacts.kind, prepared.kind)))
|
|
1228
|
+
.get()
|
|
1229
|
+
const revision = (row?.max ?? -1) + 1
|
|
1230
|
+
this.db
|
|
1231
|
+
.insert(sessionArtifacts)
|
|
1232
|
+
.values({
|
|
1233
|
+
session: id,
|
|
1234
|
+
kind: prepared.kind,
|
|
1235
|
+
revision,
|
|
1236
|
+
blobRef: prepared.blobRef,
|
|
1237
|
+
metadata: prepared.metadata,
|
|
1238
|
+
createdAt,
|
|
1239
|
+
})
|
|
1240
|
+
.run()
|
|
1241
|
+
this.db.update(sessions).set({ updatedAt: createdAt }).where(eq(sessions.id, id)).run()
|
|
1242
|
+
return {
|
|
1243
|
+
session: id,
|
|
1244
|
+
kind: prepared.kind,
|
|
1245
|
+
revision,
|
|
1246
|
+
blobRef: prepared.blobRef,
|
|
1247
|
+
metadata: prepared.metadata,
|
|
1248
|
+
createdAt,
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async appendSessionWithArtifacts<T extends SessionEventType>(
|
|
1253
|
+
id: string,
|
|
1254
|
+
artifactInputs: ArtifactInput[],
|
|
1255
|
+
makeEvent: (deposited: SessionArtifactMeta[]) => SessionEventWrite<T>,
|
|
1256
|
+
): Promise<{ event: SessionEventEnvelope<T>; artifacts: SessionArtifactMeta[] }> {
|
|
1257
|
+
const prepared: PreparedArtifact[] = []
|
|
1258
|
+
for (const input of artifactInputs) {
|
|
1259
|
+
prepared.push(await this.prepareArtifact(input))
|
|
1260
|
+
}
|
|
1261
|
+
// One synchronous transaction: deposits + event append commit together;
|
|
1262
|
+
// an invalid event throws, rolling back every deposit (D6). Session
|
|
1263
|
+
// artifacts are not retention-managed, so the AUT-322 ordering invariant
|
|
1264
|
+
// holds trivially — validation still precedes every deposit commit.
|
|
1265
|
+
return this.writeTx(() => {
|
|
1266
|
+
const deposited = prepared.map((p) => this.depositSessionInTx(id, p))
|
|
1267
|
+
const validated = validateSessionEventWrite(makeEvent(deposited))
|
|
1268
|
+
const event = this.appendSessionInTx(id, validated) as SessionEventEnvelope<T>
|
|
1269
|
+
return { event, artifacts: deposited }
|
|
1270
|
+
})
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async putSessionArtifact(id: string, artifact: ArtifactInput): Promise<SessionArtifactMeta> {
|
|
1274
|
+
const prepared = await this.prepareArtifact(artifact)
|
|
1275
|
+
return this.writeTx(() => this.depositSessionInTx(id, prepared))
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
private toSessionMeta(row: typeof sessionArtifacts.$inferSelect): SessionArtifactMeta {
|
|
1279
|
+
return {
|
|
1280
|
+
session: row.session,
|
|
1281
|
+
kind: row.kind,
|
|
1282
|
+
revision: row.revision,
|
|
1283
|
+
blobRef: row.blobRef,
|
|
1284
|
+
metadata: row.metadata,
|
|
1285
|
+
createdAt: row.createdAt,
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async getSessionArtifact(
|
|
1290
|
+
id: string,
|
|
1291
|
+
kind: string,
|
|
1292
|
+
rev?: number,
|
|
1293
|
+
): Promise<SessionArtifact | null> {
|
|
1294
|
+
this.requireSession(id)
|
|
1295
|
+
const scoped = and(eq(sessionArtifacts.session, id), eq(sessionArtifacts.kind, kind))
|
|
1296
|
+
const row =
|
|
1297
|
+
rev === undefined
|
|
1298
|
+
? this.db
|
|
1299
|
+
.select()
|
|
1300
|
+
.from(sessionArtifacts)
|
|
1301
|
+
.where(scoped)
|
|
1302
|
+
.orderBy(desc(sessionArtifacts.revision))
|
|
1303
|
+
.limit(1)
|
|
1304
|
+
.get()
|
|
1305
|
+
: this.db
|
|
1306
|
+
.select()
|
|
1307
|
+
.from(sessionArtifacts)
|
|
1308
|
+
.where(and(scoped, eq(sessionArtifacts.revision, rev)))
|
|
1309
|
+
.get()
|
|
1310
|
+
if (!row) return null
|
|
1311
|
+
const content = await this.blobs.get(row.blobRef)
|
|
1312
|
+
return content ? { meta: this.toSessionMeta(row), content } : null
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async listSessionArtifacts(id: string, kind?: string): Promise<SessionArtifactMeta[]> {
|
|
1316
|
+
this.requireSession(id)
|
|
1317
|
+
const where = kind
|
|
1318
|
+
? and(eq(sessionArtifacts.session, id), eq(sessionArtifacts.kind, kind))
|
|
1319
|
+
: eq(sessionArtifacts.session, id)
|
|
1320
|
+
return this.db
|
|
1321
|
+
.select()
|
|
1322
|
+
.from(sessionArtifacts)
|
|
1323
|
+
.where(where)
|
|
1324
|
+
.orderBy(asc(sessionArtifacts.kind), asc(sessionArtifacts.revision))
|
|
1325
|
+
.all()
|
|
1326
|
+
.map((row) => this.toSessionMeta(row))
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
subscribe(slug: string, opts: SubscribeOptions, onEvent: (event: AbEvent) => void): Unsubscribe {
|
|
1330
|
+
return pollingSubscribe((since) => this.getEvents(slug, since), opts, onEvent)
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// ── Streams (SPEC §7.6 — the third primitive) ───────────────────────────
|
|
1334
|
+
|
|
1335
|
+
private streamRow(id: string): typeof streams.$inferSelect | undefined {
|
|
1336
|
+
return this.db.select().from(streams).where(eq(streams.id, id)).get()
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
private requireStream(id: string): typeof streams.$inferSelect {
|
|
1340
|
+
const row = this.streamRow(id)
|
|
1341
|
+
if (!row) throw new Error(`unknown stream "${id}"`)
|
|
1342
|
+
return row
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
private streamScopeOf(row: typeof streams.$inferSelect): StreamScope {
|
|
1346
|
+
if (row.scopeKind === 'build' && row.build !== null) return { kind: 'build', build: row.build }
|
|
1347
|
+
if (row.scopeKind === 'repo' && row.repo !== null) return { kind: 'repo', repo: row.repo }
|
|
1348
|
+
if (row.scopeKind === 'session' && row.session !== null) {
|
|
1349
|
+
return { kind: 'session', session: row.session }
|
|
1350
|
+
}
|
|
1351
|
+
throw new Error(`stream "${row.id}" has an unreadable scope`)
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
private toStreamRecord(row: typeof streams.$inferSelect): StreamRecord {
|
|
1355
|
+
const record: StreamRecord = {
|
|
1356
|
+
id: row.id,
|
|
1357
|
+
scope: this.streamScopeOf(row),
|
|
1358
|
+
label: row.label,
|
|
1359
|
+
format: row.format as StreamRecord['format'],
|
|
1360
|
+
status: row.status as StreamRecord['status'],
|
|
1361
|
+
createdAt: row.createdAt,
|
|
1362
|
+
...(row.closedAt ? { closedAt: row.closedAt } : {}),
|
|
1363
|
+
...(row.outcome ? { outcome: row.outcome as StreamRecord['outcome'] } : {}),
|
|
1364
|
+
...(row.artifactKind && row.artifactRevision !== null && row.artifactBlobRef
|
|
1365
|
+
? {
|
|
1366
|
+
artifact: {
|
|
1367
|
+
kind: row.artifactKind,
|
|
1368
|
+
revision: row.artifactRevision,
|
|
1369
|
+
blobRef: row.artifactBlobRef,
|
|
1370
|
+
},
|
|
1371
|
+
}
|
|
1372
|
+
: {}),
|
|
1373
|
+
}
|
|
1374
|
+
return record
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** Runs inside an open transaction — see `appendInTx`. The retention rule
|
|
1378
|
+
* (deposit-path, count-based like artifact retention): keep every closed
|
|
1379
|
+
* stream's chunks except the most recently closed one in this scope,
|
|
1380
|
+
* ordering by closedAt with an id tie-break. Records, finalized artifacts,
|
|
1381
|
+
* and open streams are never touched. */
|
|
1382
|
+
private pruneStreamChunksInTx(scope: StreamScope): void {
|
|
1383
|
+
const owner =
|
|
1384
|
+
scope.kind === 'build' ? scope.build : scope.kind === 'repo' ? scope.repo : scope.session
|
|
1385
|
+
const closed = this.db
|
|
1386
|
+
.select()
|
|
1387
|
+
.from(streams)
|
|
1388
|
+
.where(and(eq(streams.status, 'closed'), eq(streams.scopeKind, scope.kind)))
|
|
1389
|
+
.all()
|
|
1390
|
+
.filter((row) =>
|
|
1391
|
+
scope.kind === 'build'
|
|
1392
|
+
? row.build === owner
|
|
1393
|
+
: scope.kind === 'repo'
|
|
1394
|
+
? row.repo === owner
|
|
1395
|
+
: row.session === owner,
|
|
1396
|
+
)
|
|
1397
|
+
.sort(
|
|
1398
|
+
(a, b) => (a.closedAt ?? '').localeCompare(b.closedAt ?? '') || a.id.localeCompare(b.id),
|
|
1399
|
+
)
|
|
1400
|
+
for (const row of closed.slice(0, -1)) {
|
|
1401
|
+
this.db.delete(streamChunks).where(eq(streamChunks.stream, row.id)).run()
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async createStream(scope: StreamScope, label: string): Promise<StreamRecord> {
|
|
1406
|
+
if (!label) throw new Error('stream label is required')
|
|
1407
|
+
if (scope.kind === 'build') this.requireBuild(scope.build)
|
|
1408
|
+
else if (scope.kind === 'repo') this.requireRepo(scope.repo)
|
|
1409
|
+
else this.requireSession(scope.session)
|
|
1410
|
+
const id = `st_${crypto.randomUUID()}`
|
|
1411
|
+
return this.writeTx(() => {
|
|
1412
|
+
// Retention prune and the insert land in one transaction.
|
|
1413
|
+
this.pruneStreamChunksInTx(scope)
|
|
1414
|
+
// Store-assigned monotonic creation sequence: MAX+1 inside the write
|
|
1415
|
+
// transaction (every write runs BEGIN IMMEDIATE, so writers are
|
|
1416
|
+
// serialized) — the same pattern as the seq assignments in this file.
|
|
1417
|
+
// Streams are never deleted (retention prunes stream_chunks only), so
|
|
1418
|
+
// the counter is never reused and the listStreams same-millisecond
|
|
1419
|
+
// tiebreak is stable over time.
|
|
1420
|
+
const tail = this.db
|
|
1421
|
+
.select({ max: sql<number | null>`max(${streams.creationSeq})` })
|
|
1422
|
+
.from(streams)
|
|
1423
|
+
.get()
|
|
1424
|
+
const creationSeq = (tail?.max ?? 0) + 1
|
|
1425
|
+
this.db
|
|
1426
|
+
.insert(streams)
|
|
1427
|
+
.values({
|
|
1428
|
+
id,
|
|
1429
|
+
scopeKind: scope.kind,
|
|
1430
|
+
build: scope.kind === 'build' ? scope.build : null,
|
|
1431
|
+
repo: scope.kind === 'repo' ? scope.repo : null,
|
|
1432
|
+
session: scope.kind === 'session' ? scope.session : null,
|
|
1433
|
+
label,
|
|
1434
|
+
format: STREAM_FORMAT,
|
|
1435
|
+
status: 'open',
|
|
1436
|
+
createdAt: this.now(),
|
|
1437
|
+
creationSeq,
|
|
1438
|
+
})
|
|
1439
|
+
.run()
|
|
1440
|
+
return this.toStreamRecord(this.requireStream(id))
|
|
1441
|
+
})
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async appendStreamParts(streamId: string, parts: StreamPart[]): Promise<StreamChunk> {
|
|
1445
|
+
// Validate and size-check before taking the per-stream lock, so invalid
|
|
1446
|
+
// input keeps its current error precedence (AUT-348).
|
|
1447
|
+
validateStreamParts(parts)
|
|
1448
|
+
const bytes = serializedBatchSize(parts)
|
|
1449
|
+
if (bytes > STREAM_BATCH_MAX_BYTES) throw new StreamBatchTooLargeError(bytes)
|
|
1450
|
+
// The per-stream lock covers the closed check and the transaction as one
|
|
1451
|
+
// critical section: an append issued during a same-process close waits
|
|
1452
|
+
// for the close to commit, then fails the status check with an explicit
|
|
1453
|
+
// StreamClosedError instead of being silently omitted from the artifact.
|
|
1454
|
+
return this.streamLocks.run(streamId, () =>
|
|
1455
|
+
this.writeTx(() => {
|
|
1456
|
+
const row = this.requireStream(streamId)
|
|
1457
|
+
if (row.status === 'closed') throw new StreamClosedError(streamId)
|
|
1458
|
+
const tails = this.db
|
|
1459
|
+
.select({ max: sql<number | null>`max(${streamChunks.seq})` })
|
|
1460
|
+
.from(streamChunks)
|
|
1461
|
+
.where(eq(streamChunks.stream, streamId))
|
|
1462
|
+
.get()
|
|
1463
|
+
const chunk: StreamChunk = {
|
|
1464
|
+
stream: streamId,
|
|
1465
|
+
seq: (tails?.max ?? 0) + 1,
|
|
1466
|
+
ts: this.now(),
|
|
1467
|
+
parts: structuredClone(parts),
|
|
1468
|
+
}
|
|
1469
|
+
this.db
|
|
1470
|
+
.insert(streamChunks)
|
|
1471
|
+
.values({ stream: streamId, seq: chunk.seq, ts: chunk.ts, parts: chunk.parts })
|
|
1472
|
+
.run()
|
|
1473
|
+
return chunk
|
|
1474
|
+
}),
|
|
1475
|
+
)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async readStream(
|
|
1479
|
+
streamId: string,
|
|
1480
|
+
opts?: { since?: number; waitSeconds?: number; signal?: AbortSignal },
|
|
1481
|
+
): Promise<StreamRead> {
|
|
1482
|
+
const read = async (): Promise<StreamRead> => {
|
|
1483
|
+
const row = this.requireStream(streamId)
|
|
1484
|
+
const chunks = this.db
|
|
1485
|
+
.select()
|
|
1486
|
+
.from(streamChunks)
|
|
1487
|
+
.where(and(eq(streamChunks.stream, streamId), gt(streamChunks.seq, opts?.since ?? 0)))
|
|
1488
|
+
.orderBy(asc(streamChunks.seq))
|
|
1489
|
+
.all()
|
|
1490
|
+
const record = this.toStreamRecord(row)
|
|
1491
|
+
return {
|
|
1492
|
+
chunks: chunks.map((chunk) => ({
|
|
1493
|
+
stream: chunk.stream,
|
|
1494
|
+
seq: chunk.seq,
|
|
1495
|
+
ts: chunk.ts,
|
|
1496
|
+
parts: chunk.parts,
|
|
1497
|
+
})),
|
|
1498
|
+
status: record.status,
|
|
1499
|
+
...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
|
|
1500
|
+
...(record.artifact !== undefined ? { artifact: record.artifact } : {}),
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
return readStreamWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
async closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord> {
|
|
1507
|
+
// The per-stream mutex holds across the entire prepare→commit loop
|
|
1508
|
+
// (AUT-348): a same-process append issued during this close waits for it
|
|
1509
|
+
// to finish and then rejects on the closed check with StreamClosedError.
|
|
1510
|
+
// Cross-connection appends (a second instance on the same file) are
|
|
1511
|
+
// invisible to the mutex, so the commit transaction re-verifies the
|
|
1512
|
+
// chunk tail and a mismatch retries the whole loop from a newer snapshot.
|
|
1513
|
+
return this.streamLocks.run(streamId, async () => {
|
|
1514
|
+
for (;;) {
|
|
1515
|
+
const row = this.requireStream(streamId)
|
|
1516
|
+
if (row.status === 'closed') return this.toStreamRecord(row)
|
|
1517
|
+
// Prepare phase — assemble and store the blob before the transaction
|
|
1518
|
+
// (D6 shape: content-addressed orphan blobs are harmless; a deposit
|
|
1519
|
+
// failure leaves the stream open and unwritten).
|
|
1520
|
+
const chunkRows = this.db
|
|
1521
|
+
.select()
|
|
1522
|
+
.from(streamChunks)
|
|
1523
|
+
.where(eq(streamChunks.stream, streamId))
|
|
1524
|
+
.orderBy(asc(streamChunks.seq))
|
|
1525
|
+
.all()
|
|
1526
|
+
const { document, droppedPartCount } = await assembleUIMessageDocument(
|
|
1527
|
+
chunkRows.flatMap((chunk) => chunk.parts),
|
|
1528
|
+
)
|
|
1529
|
+
const scope = this.streamScopeOf(row)
|
|
1530
|
+
const input = streamArtifactInput(
|
|
1531
|
+
row.id,
|
|
1532
|
+
scope,
|
|
1533
|
+
row.label,
|
|
1534
|
+
outcome,
|
|
1535
|
+
document,
|
|
1536
|
+
chunkRows.length,
|
|
1537
|
+
droppedPartCount,
|
|
1538
|
+
)
|
|
1539
|
+
const prepared: PreparedArtifact = {
|
|
1540
|
+
kind: input.kind,
|
|
1541
|
+
blobRef: contentHash(toBytes(input.content)),
|
|
1542
|
+
metadata: structuredClone(input.metadata),
|
|
1543
|
+
}
|
|
1544
|
+
await this.blobs.put(prepared.blobRef, toBytes(input.content))
|
|
1545
|
+
// Commit phase — one synchronous transaction: the artifact deposit
|
|
1546
|
+
// and the close land together or not at all. A close that raced us
|
|
1547
|
+
// through the prepare phase wins; ours re-reads and returns its
|
|
1548
|
+
// record. The chunk tail is re-read under the same write lock:
|
|
1549
|
+
// chunk seqs are gapless 1..n (assigned as MAX+1, and retention
|
|
1550
|
+
// never deletes the closing stream's chunks), so a MAX other than
|
|
1551
|
+
// the prepared length means a foreign appender landed inside the
|
|
1552
|
+
// prepare window.
|
|
1553
|
+
const committed = this.writeTx(
|
|
1554
|
+
(): { stale: true } | { stale: false; record: StreamRecord } => {
|
|
1555
|
+
const fresh = this.requireStream(streamId)
|
|
1556
|
+
if (fresh.status === 'closed')
|
|
1557
|
+
return { stale: false, record: this.toStreamRecord(fresh) }
|
|
1558
|
+
const tail = this.db
|
|
1559
|
+
.select({ max: sql<number | null>`max(${streamChunks.seq})` })
|
|
1560
|
+
.from(streamChunks)
|
|
1561
|
+
.where(eq(streamChunks.stream, streamId))
|
|
1562
|
+
.get()
|
|
1563
|
+
if ((tail?.max ?? 0) !== chunkRows.length) return { stale: true }
|
|
1564
|
+
const meta =
|
|
1565
|
+
scope.kind === 'build'
|
|
1566
|
+
? this.depositInTx(scope.build, prepared)
|
|
1567
|
+
: scope.kind === 'repo'
|
|
1568
|
+
? this.depositRepoInTx(scope.repo, prepared)
|
|
1569
|
+
: this.depositSessionInTx(scope.session, prepared)
|
|
1570
|
+
this.db
|
|
1571
|
+
.update(streams)
|
|
1572
|
+
.set({
|
|
1573
|
+
status: 'closed',
|
|
1574
|
+
outcome,
|
|
1575
|
+
closedAt: meta.createdAt,
|
|
1576
|
+
artifactKind: meta.kind,
|
|
1577
|
+
artifactRevision: meta.revision,
|
|
1578
|
+
artifactBlobRef: meta.blobRef,
|
|
1579
|
+
})
|
|
1580
|
+
.where(eq(streams.id, streamId))
|
|
1581
|
+
.run()
|
|
1582
|
+
return { stale: false, record: this.toStreamRecord(this.requireStream(streamId)) }
|
|
1583
|
+
},
|
|
1584
|
+
)
|
|
1585
|
+
if (!committed.stale) return committed.record
|
|
1586
|
+
// A foreign appender landed inside the prepare window; loop and
|
|
1587
|
+
// re-prepare from the newer chunk set so its acknowledged chunk is
|
|
1588
|
+
// not omitted from the finalized artifact.
|
|
1589
|
+
}
|
|
1590
|
+
})
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
async getStream(streamId: string): Promise<StreamRecord | null> {
|
|
1594
|
+
const row = this.streamRow(streamId)
|
|
1595
|
+
return row ? this.toStreamRecord(row) : null
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
async listStreams(scope: StreamScope): Promise<StreamRecord[]> {
|
|
1599
|
+
const owner =
|
|
1600
|
+
scope.kind === 'build' ? scope.build : scope.kind === 'repo' ? scope.repo : scope.session
|
|
1601
|
+
// Pinned tiebreak (store/types.ts): createdAt ascending, then the
|
|
1602
|
+
// store-assigned monotonic creation sequence — never the random id.
|
|
1603
|
+
const rows = this.db
|
|
1604
|
+
.select()
|
|
1605
|
+
.from(streams)
|
|
1606
|
+
.where(eq(streams.scopeKind, scope.kind))
|
|
1607
|
+
.orderBy(asc(streams.createdAt), asc(streams.creationSeq))
|
|
1608
|
+
.all()
|
|
1609
|
+
.filter((row) =>
|
|
1610
|
+
scope.kind === 'build'
|
|
1611
|
+
? row.scopeKind === 'build' && row.build === owner
|
|
1612
|
+
: scope.kind === 'repo'
|
|
1613
|
+
? row.scopeKind === 'repo' && row.repo === owner
|
|
1614
|
+
: row.scopeKind === 'session' && row.session === owner,
|
|
1615
|
+
)
|
|
1616
|
+
return rows.map((row) => this.toStreamRecord(row))
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
async close(): Promise<void> {
|
|
1620
|
+
this.sqlite.close()
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
export type LocalStoreInspection =
|
|
1625
|
+
| { status: 'absent'; databasePath: string }
|
|
1626
|
+
| { status: 'present'; databasePath: string; buildCount: number }
|
|
1627
|
+
|
|
1628
|
+
function isMissing(error: unknown): boolean {
|
|
1629
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Inspect a repository-local Store without asking SQLite to open its source
|
|
1634
|
+
* files. Bun can create WAL/SHM files even for a read-only connection, so the
|
|
1635
|
+
* database and any live sidecars are copied to a disposable directory first.
|
|
1636
|
+
*/
|
|
1637
|
+
export async function inspectLocalStoreSnapshot(rootDir: string): Promise<LocalStoreInspection> {
|
|
1638
|
+
const databasePath = join(rootDir, 'autobuild.sqlite')
|
|
1639
|
+
try {
|
|
1640
|
+
await access(databasePath)
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
if (isMissing(error)) return { status: 'absent', databasePath }
|
|
1643
|
+
throw error
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
const snapshotRoot = await mkdtemp(join(tmpdir(), 'ab-store-inspection-'))
|
|
1647
|
+
const snapshotPath = join(snapshotRoot, 'autobuild.sqlite')
|
|
1648
|
+
let database: Database | undefined
|
|
1649
|
+
let result: LocalStoreInspection | undefined
|
|
1650
|
+
let failure: unknown
|
|
1651
|
+
const cleanupFailures: unknown[] = []
|
|
1652
|
+
try {
|
|
1653
|
+
await copyFile(databasePath, snapshotPath)
|
|
1654
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
1655
|
+
try {
|
|
1656
|
+
await copyFile(`${databasePath}${suffix}`, `${snapshotPath}${suffix}`)
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
if (!isMissing(error)) throw error
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
database = new Database(snapshotPath, { readonly: true })
|
|
1662
|
+
const row = database.query('SELECT count(*) AS count FROM builds').get() as {
|
|
1663
|
+
count: number
|
|
1664
|
+
} | null
|
|
1665
|
+
if (row === null) throw new Error('local Store builds count returned no row')
|
|
1666
|
+
result = { status: 'present', databasePath, buildCount: row.count }
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
failure = error
|
|
1669
|
+
} finally {
|
|
1670
|
+
try {
|
|
1671
|
+
database?.close()
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
cleanupFailures.push(error)
|
|
1674
|
+
}
|
|
1675
|
+
try {
|
|
1676
|
+
await rm(snapshotRoot, { recursive: true, force: true })
|
|
1677
|
+
} catch (error) {
|
|
1678
|
+
cleanupFailures.push(error)
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
if (failure !== undefined && cleanupFailures.length > 0) {
|
|
1683
|
+
throw new AggregateError(
|
|
1684
|
+
[failure, ...cleanupFailures],
|
|
1685
|
+
'local Store snapshot inspection and cleanup both failed',
|
|
1686
|
+
)
|
|
1687
|
+
}
|
|
1688
|
+
if (failure !== undefined) throw failure
|
|
1689
|
+
if (cleanupFailures.length === 1) throw cleanupFailures[0]
|
|
1690
|
+
if (cleanupFailures.length > 1)
|
|
1691
|
+
throw new AggregateError(cleanupFailures, 'local Store snapshot cleanup failed')
|
|
1692
|
+
if (result === undefined) throw new Error('local Store snapshot inspection produced no result')
|
|
1693
|
+
return result
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Open the local store (SPEC §7.2.1): `<root>/autobuild.sqlite` plus a
|
|
1698
|
+
* content-addressed blob directory at `<root>/blobs`.
|
|
1699
|
+
*/
|
|
1700
|
+
export function openLocalStore(
|
|
1701
|
+
rootDir: string,
|
|
1702
|
+
opts: { clock?: Clock; retention?: { maxRevisions?: number } } = {},
|
|
1703
|
+
): SqliteBuildStore {
|
|
1704
|
+
mkdirSync(rootDir, { recursive: true })
|
|
1705
|
+
const database = new Database(join(rootDir, 'autobuild.sqlite'), { create: true })
|
|
1706
|
+
const blobs = new DirBlobStore(join(rootDir, 'blobs'))
|
|
1707
|
+
return new SqliteBuildStore({
|
|
1708
|
+
database,
|
|
1709
|
+
blobs,
|
|
1710
|
+
...(opts.clock ? { clock: opts.clock } : {}),
|
|
1711
|
+
...(opts.retention ? { retention: opts.retention } : {}),
|
|
1712
|
+
})
|
|
1713
|
+
}
|