@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,1138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory BuildStore — the reference adapter. Its behavior *is* the
|
|
3
|
+
* contract: the suite in `store/contract.ts` is written against this and
|
|
4
|
+
* every other adapter (SQLite, remote HTTP) must match it.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
validateEventWrite,
|
|
8
|
+
type AbEvent,
|
|
9
|
+
type EventEnvelope,
|
|
10
|
+
type EventWrite,
|
|
11
|
+
} from '../events/catalog'
|
|
12
|
+
import type { EventType } from '../events/payloads'
|
|
13
|
+
import {
|
|
14
|
+
validateRepositoryEventWrite,
|
|
15
|
+
type RepositoryEvent,
|
|
16
|
+
type RepositoryEventEnvelope,
|
|
17
|
+
type RepositoryEventType,
|
|
18
|
+
type RepositoryEventWrite,
|
|
19
|
+
} from '../events/repository'
|
|
20
|
+
import {
|
|
21
|
+
validateSessionEventWrite,
|
|
22
|
+
type SessionEvent,
|
|
23
|
+
type SessionEventEnvelope,
|
|
24
|
+
type SessionEventType,
|
|
25
|
+
type SessionEventWrite,
|
|
26
|
+
} from '../events/sessions'
|
|
27
|
+
import { humanActor } from '../events/envelope'
|
|
28
|
+
import { createBuildScopedStore } from './build-scope'
|
|
29
|
+
import { createSessionScopedStore } from './session-handle'
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
|
|
32
|
+
isRetentionManagedKind,
|
|
33
|
+
revisionsToPrune,
|
|
34
|
+
} from './retention'
|
|
35
|
+
import { pollingSubscribe } from './subscribe'
|
|
36
|
+
import { StreamLocks } from './streams/lock'
|
|
37
|
+
import { assembleUIMessageDocument } from './streams/assemble'
|
|
38
|
+
import { EVENT_WAIT_POLL_MS, readEventsWithWait, readStreamWithWait } from './streams/wait'
|
|
39
|
+
import {
|
|
40
|
+
serializedBatchSize,
|
|
41
|
+
STREAM_BATCH_MAX_BYTES,
|
|
42
|
+
STREAM_FORMAT,
|
|
43
|
+
StreamBatchTooLargeError,
|
|
44
|
+
StreamClosedError,
|
|
45
|
+
streamArtifactInput,
|
|
46
|
+
validateStreamParts,
|
|
47
|
+
type StreamChunk,
|
|
48
|
+
type StreamOutcome,
|
|
49
|
+
type StreamPart,
|
|
50
|
+
type StreamRead,
|
|
51
|
+
type StreamRecord,
|
|
52
|
+
type StreamScope,
|
|
53
|
+
} from './streams/types'
|
|
54
|
+
import {
|
|
55
|
+
contentHash,
|
|
56
|
+
normalizeOperator,
|
|
57
|
+
systemClock,
|
|
58
|
+
toBytes,
|
|
59
|
+
validateExpectedSeq,
|
|
60
|
+
type Artifact,
|
|
61
|
+
type ArtifactInput,
|
|
62
|
+
type ArtifactMeta,
|
|
63
|
+
type BlobStore,
|
|
64
|
+
type BuildRecord,
|
|
65
|
+
type BuildScopedStore,
|
|
66
|
+
type BuildStore,
|
|
67
|
+
type Clock,
|
|
68
|
+
type NewBuildInput,
|
|
69
|
+
type NewSessionInput,
|
|
70
|
+
type RepositoryArtifact,
|
|
71
|
+
type RepositoryArtifactMeta,
|
|
72
|
+
type RepositoryRecord,
|
|
73
|
+
type SessionArtifact,
|
|
74
|
+
type SessionArtifactMeta,
|
|
75
|
+
type SessionRecord,
|
|
76
|
+
type SessionScopedStore,
|
|
77
|
+
type SubscribeOptions,
|
|
78
|
+
type Unsubscribe,
|
|
79
|
+
} from './types'
|
|
80
|
+
|
|
81
|
+
export class MemoryBlobStore implements BlobStore {
|
|
82
|
+
private readonly blobs = new Map<string, Uint8Array>()
|
|
83
|
+
|
|
84
|
+
async put(hash: string, bytes: Uint8Array): Promise<void> {
|
|
85
|
+
this.blobs.set(hash, bytes.slice())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async get(hash: string): Promise<Uint8Array | null> {
|
|
89
|
+
const bytes = this.blobs.get(hash)
|
|
90
|
+
return bytes ? bytes.slice() : null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Lease {
|
|
95
|
+
holder: string
|
|
96
|
+
expiresAt: number
|
|
97
|
+
ttlMs: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface RepoState {
|
|
101
|
+
record: {
|
|
102
|
+
repo: string
|
|
103
|
+
createdAt: string
|
|
104
|
+
updatedAt: string
|
|
105
|
+
heartbeatAt?: string
|
|
106
|
+
}
|
|
107
|
+
lease?: Lease
|
|
108
|
+
events: RepositoryEvent[]
|
|
109
|
+
artifacts: Map<string, RepositoryArtifactMeta[]>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface BuildState {
|
|
113
|
+
record: {
|
|
114
|
+
slug: string
|
|
115
|
+
repo: string
|
|
116
|
+
repoOrigin?: NewBuildInput['repoOrigin']
|
|
117
|
+
ticket?: NewBuildInput['ticket']
|
|
118
|
+
branch?: string
|
|
119
|
+
createdAt: string
|
|
120
|
+
updatedAt: string
|
|
121
|
+
heartbeatAt?: string
|
|
122
|
+
}
|
|
123
|
+
lease?: Lease
|
|
124
|
+
events: AbEvent[]
|
|
125
|
+
/** kind → deposits in revision order (index = revision; 0-based, §6.3). */
|
|
126
|
+
artifacts: Map<string, ArtifactMeta[]>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface StreamState {
|
|
130
|
+
record: StreamRecord
|
|
131
|
+
chunks: StreamChunk[]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface SessionState {
|
|
135
|
+
record: SessionRecord
|
|
136
|
+
events: SessionEvent[]
|
|
137
|
+
/** kind → deposits in revision order (index = revision; 0-based, §6.3). */
|
|
138
|
+
artifacts: Map<string, SessionArtifactMeta[]>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class MemoryBuildStore implements BuildStore {
|
|
142
|
+
private readonly builds = new Map<string, BuildState>()
|
|
143
|
+
private readonly repos = new Map<string, RepoState>()
|
|
144
|
+
private readonly streams = new Map<string, StreamState>()
|
|
145
|
+
/** Per-stream in-process mutex (store/streams/lock.ts): serializes a
|
|
146
|
+
* stream's close against its appends inside this process (AUT-348). */
|
|
147
|
+
private readonly streamLocks = new StreamLocks()
|
|
148
|
+
private readonly sessions = new Map<string, SessionState>()
|
|
149
|
+
private readonly clock: Clock
|
|
150
|
+
private readonly maxRevisions: number
|
|
151
|
+
readonly blobs: BlobStore
|
|
152
|
+
|
|
153
|
+
constructor(
|
|
154
|
+
opts: {
|
|
155
|
+
clock?: Clock
|
|
156
|
+
blobs?: BlobStore
|
|
157
|
+
retention?: { maxRevisions?: number }
|
|
158
|
+
} = {},
|
|
159
|
+
) {
|
|
160
|
+
this.clock = opts.clock ?? systemClock
|
|
161
|
+
this.blobs = opts.blobs ?? new MemoryBlobStore()
|
|
162
|
+
this.maxRevisions = opts.retention?.maxRevisions ?? DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Newest surviving revision of one kind — revisions prune from the front,
|
|
166
|
+
* so the array length is no longer the next revision (retention policy). */
|
|
167
|
+
private static nextRevision(revs: { revision: number }[] | undefined): number {
|
|
168
|
+
return (revs?.at(-1)?.revision ?? -1) + 1
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Deposit-time pruning (store/retention.ts): drop revisions past the bound
|
|
172
|
+
* for retention-managed kinds, inside the same critical section as the
|
|
173
|
+
* deposit itself. Non-family kinds are never touched. */
|
|
174
|
+
private pruneBuildKind(state: BuildState, kind: string): void {
|
|
175
|
+
if (!isRetentionManagedKind(kind)) return
|
|
176
|
+
const revs = state.artifacts.get(kind)
|
|
177
|
+
if (!revs) return
|
|
178
|
+
const pruned = revisionsToPrune(
|
|
179
|
+
revs.map((meta) => meta.revision),
|
|
180
|
+
this.maxRevisions,
|
|
181
|
+
)
|
|
182
|
+
if (pruned.length === 0) return
|
|
183
|
+
const drop = new Set(pruned)
|
|
184
|
+
state.artifacts.set(
|
|
185
|
+
kind,
|
|
186
|
+
revs.filter((meta) => !drop.has(meta.revision)),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private pruneRepoKind(state: RepoState, kind: string): void {
|
|
191
|
+
if (!isRetentionManagedKind(kind)) return
|
|
192
|
+
const revisions = state.artifacts.get(kind)
|
|
193
|
+
if (!revisions) return
|
|
194
|
+
const pruned = revisionsToPrune(
|
|
195
|
+
revisions.map((meta) => meta.revision),
|
|
196
|
+
this.maxRevisions,
|
|
197
|
+
)
|
|
198
|
+
if (pruned.length === 0) return
|
|
199
|
+
const drop = new Set(pruned)
|
|
200
|
+
state.artifacts.set(
|
|
201
|
+
kind,
|
|
202
|
+
revisions.filter((meta) => !drop.has(meta.revision)),
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
scopeBuild(slug: string): BuildScopedStore {
|
|
207
|
+
return createBuildScopedStore(this, slug)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
scopeSession(id: string): SessionScopedStore {
|
|
211
|
+
return createSessionScopedStore(this, id)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private now(): string {
|
|
215
|
+
return this.clock().toISOString()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private state(slug: string): BuildState {
|
|
219
|
+
const state = this.builds.get(slug)
|
|
220
|
+
if (!state) throw new Error(`unknown build "${slug}"`)
|
|
221
|
+
return state
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private snapshot(state: BuildState): BuildRecord {
|
|
225
|
+
const { record, lease } = state
|
|
226
|
+
return {
|
|
227
|
+
slug: record.slug,
|
|
228
|
+
repo: record.repo,
|
|
229
|
+
...(record.ticket ? { ticket: structuredClone(record.ticket) } : {}),
|
|
230
|
+
...(record.branch ? { branch: record.branch } : {}),
|
|
231
|
+
...(record.repoOrigin !== undefined ? { repoOrigin: record.repoOrigin } : {}),
|
|
232
|
+
createdAt: record.createdAt,
|
|
233
|
+
updatedAt: record.updatedAt,
|
|
234
|
+
...(record.heartbeatAt ? { heartbeatAt: record.heartbeatAt } : {}),
|
|
235
|
+
...(lease
|
|
236
|
+
? {
|
|
237
|
+
lease: {
|
|
238
|
+
holder: lease.holder,
|
|
239
|
+
expiresAt: new Date(lease.expiresAt).toISOString(),
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
: {}),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async createBuild(input: NewBuildInput): Promise<BuildRecord> {
|
|
247
|
+
if (this.builds.has(input.slug)) {
|
|
248
|
+
throw new Error(`build "${input.slug}" already exists`)
|
|
249
|
+
}
|
|
250
|
+
const ts = this.now()
|
|
251
|
+
const state: BuildState = {
|
|
252
|
+
record: {
|
|
253
|
+
slug: input.slug,
|
|
254
|
+
repo: input.repo,
|
|
255
|
+
...(input.repoOrigin !== undefined ? { repoOrigin: input.repoOrigin } : {}),
|
|
256
|
+
...(input.ticket ? { ticket: structuredClone(input.ticket) } : {}),
|
|
257
|
+
...(input.branch ? { branch: input.branch } : {}),
|
|
258
|
+
createdAt: ts,
|
|
259
|
+
updatedAt: ts,
|
|
260
|
+
},
|
|
261
|
+
events: [],
|
|
262
|
+
artifacts: new Map(),
|
|
263
|
+
}
|
|
264
|
+
this.builds.set(input.slug, state)
|
|
265
|
+
return this.snapshot(state)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async getBuild(slug: string): Promise<BuildRecord | null> {
|
|
269
|
+
const state = this.builds.get(slug)
|
|
270
|
+
return state ? this.snapshot(state) : null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async listBuilds(): Promise<BuildRecord[]> {
|
|
274
|
+
return [...this.builds.values()].map((state) => this.snapshot(state))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
|
|
278
|
+
const state = this.state(slug)
|
|
279
|
+
const validated = validateEventWrite(event)
|
|
280
|
+
const envelope = {
|
|
281
|
+
build: slug,
|
|
282
|
+
seq: state.events.length + 1,
|
|
283
|
+
ts: this.now(),
|
|
284
|
+
actor: validated.actor,
|
|
285
|
+
type: validated.type,
|
|
286
|
+
payload: validated.payload,
|
|
287
|
+
} as EventEnvelope<T>
|
|
288
|
+
state.events.push(structuredClone(envelope) as AbEvent)
|
|
289
|
+
state.record.updatedAt = envelope.ts
|
|
290
|
+
return envelope
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async appendIfCurrent<T extends EventType>(
|
|
294
|
+
slug: string,
|
|
295
|
+
expectedSeq: number,
|
|
296
|
+
event: EventWrite<T>,
|
|
297
|
+
): Promise<EventEnvelope<T> | null> {
|
|
298
|
+
const state = this.state(slug)
|
|
299
|
+
validateExpectedSeq(expectedSeq)
|
|
300
|
+
const validated = validateEventWrite(event)
|
|
301
|
+
|
|
302
|
+
// No await occurs between comparison and mutation. JavaScript's run-to-
|
|
303
|
+
// completion semantics make this one atomic critical section even when
|
|
304
|
+
// concurrent remote requests interleave elsewhere in the adapter.
|
|
305
|
+
if (state.events.length !== expectedSeq) return null
|
|
306
|
+
const envelope = {
|
|
307
|
+
build: slug,
|
|
308
|
+
seq: expectedSeq + 1,
|
|
309
|
+
ts: this.now(),
|
|
310
|
+
actor: validated.actor,
|
|
311
|
+
type: validated.type,
|
|
312
|
+
payload: validated.payload,
|
|
313
|
+
} as EventEnvelope<T>
|
|
314
|
+
state.events.push(structuredClone(envelope) as AbEvent)
|
|
315
|
+
state.record.updatedAt = envelope.ts
|
|
316
|
+
return envelope
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async appendWithArtifacts<T extends EventType>(
|
|
320
|
+
slug: string,
|
|
321
|
+
artifacts: ArtifactInput[],
|
|
322
|
+
makeEvent: (deposited: ArtifactMeta[]) => EventWrite<T>,
|
|
323
|
+
): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
|
|
324
|
+
const state = this.state(slug)
|
|
325
|
+
// Prepare phase — validate every input and store the blobs *before*
|
|
326
|
+
// touching build state, mirroring the SQLite adapter: an invalid input
|
|
327
|
+
// mid-bundle persists nothing, and orphaned blobs are harmless because
|
|
328
|
+
// they are content-addressed (D6, §8.5).
|
|
329
|
+
const prepared: { kind: string; blobRef: string; metadata: Record<string, unknown> }[] = []
|
|
330
|
+
for (const artifact of artifacts) {
|
|
331
|
+
if (!artifact.kind) throw new Error('artifact kind is required')
|
|
332
|
+
const bytes = toBytes(artifact.content)
|
|
333
|
+
const blobRef = contentHash(bytes)
|
|
334
|
+
await this.blobs.put(blobRef, bytes)
|
|
335
|
+
prepared.push({
|
|
336
|
+
kind: artifact.kind,
|
|
337
|
+
blobRef,
|
|
338
|
+
metadata: structuredClone(artifact.metadata ?? {}),
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
// Commit phase — fully synchronous (no await), so no interleaved writer
|
|
342
|
+
// can slip between revision assignment, event validation, and the
|
|
343
|
+
// deposits landing: "there is no state where an artifact exists without
|
|
344
|
+
// its event or vice versa" (D6, §8.5). Everything is validated before
|
|
345
|
+
// the first mutation, so no rollback path exists to get wrong.
|
|
346
|
+
//
|
|
347
|
+
// Ordering invariant (AUT-322): event validation precedes any retention
|
|
348
|
+
// prune — validation runs before the deposit loop below, and the per-kind
|
|
349
|
+
// prune happens inside that loop after the deposit, so a same-kind batch
|
|
350
|
+
// whose prune scope covers a sibling never deletes that sibling before
|
|
351
|
+
// the batch's event is validated.
|
|
352
|
+
const ts = this.now()
|
|
353
|
+
const nextRev = new Map<string, number>()
|
|
354
|
+
const deposited: ArtifactMeta[] = prepared.map((p) => {
|
|
355
|
+
const revision =
|
|
356
|
+
nextRev.get(p.kind) ?? MemoryBuildStore.nextRevision(state.artifacts.get(p.kind))
|
|
357
|
+
nextRev.set(p.kind, revision + 1)
|
|
358
|
+
return {
|
|
359
|
+
build: slug,
|
|
360
|
+
kind: p.kind,
|
|
361
|
+
revision,
|
|
362
|
+
blobRef: p.blobRef,
|
|
363
|
+
metadata: p.metadata,
|
|
364
|
+
createdAt: ts,
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
const validated = validateEventWrite(makeEvent(structuredClone(deposited)))
|
|
368
|
+
for (const meta of deposited) {
|
|
369
|
+
const revs = state.artifacts.get(meta.kind) ?? []
|
|
370
|
+
revs.push(meta)
|
|
371
|
+
state.artifacts.set(meta.kind, revs)
|
|
372
|
+
this.pruneBuildKind(state, meta.kind)
|
|
373
|
+
}
|
|
374
|
+
const envelope = {
|
|
375
|
+
build: slug,
|
|
376
|
+
seq: state.events.length + 1,
|
|
377
|
+
ts,
|
|
378
|
+
actor: validated.actor,
|
|
379
|
+
type: validated.type,
|
|
380
|
+
payload: validated.payload,
|
|
381
|
+
} as EventEnvelope<T>
|
|
382
|
+
state.events.push(structuredClone(envelope) as AbEvent)
|
|
383
|
+
state.record.updatedAt = ts
|
|
384
|
+
return { event: envelope, artifacts: structuredClone(deposited) }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async getEvents(
|
|
388
|
+
slug: string,
|
|
389
|
+
sinceSeq = 0,
|
|
390
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
391
|
+
): Promise<AbEvent[]> {
|
|
392
|
+
return readEventsWithWait({
|
|
393
|
+
read: async () => {
|
|
394
|
+
const state = this.state(slug)
|
|
395
|
+
return structuredClone(state.events.filter((e) => e.seq > sinceSeq)) as AbEvent[]
|
|
396
|
+
},
|
|
397
|
+
waitSeconds: opts?.waitSeconds,
|
|
398
|
+
signal: opts?.signal,
|
|
399
|
+
// Hosted budget: held build-event reads poll at most once per second
|
|
400
|
+
// (AUT-383) — not the 25 ms stream default, which is presentation
|
|
401
|
+
// content's cadence.
|
|
402
|
+
pollMs: EVENT_WAIT_POLL_MS,
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async putArtifact(slug: string, artifact: ArtifactInput): Promise<ArtifactMeta> {
|
|
407
|
+
const state = this.state(slug)
|
|
408
|
+
if (!artifact.kind) throw new Error('artifact kind is required')
|
|
409
|
+
const bytes = toBytes(artifact.content)
|
|
410
|
+
const blobRef = contentHash(bytes)
|
|
411
|
+
await this.blobs.put(blobRef, bytes)
|
|
412
|
+
const revs = state.artifacts.get(artifact.kind) ?? []
|
|
413
|
+
const meta: ArtifactMeta = {
|
|
414
|
+
build: slug,
|
|
415
|
+
kind: artifact.kind,
|
|
416
|
+
revision: MemoryBuildStore.nextRevision(revs),
|
|
417
|
+
blobRef,
|
|
418
|
+
metadata: structuredClone(artifact.metadata ?? {}),
|
|
419
|
+
createdAt: this.now(),
|
|
420
|
+
}
|
|
421
|
+
revs.push(meta)
|
|
422
|
+
state.artifacts.set(artifact.kind, revs)
|
|
423
|
+
this.pruneBuildKind(state, artifact.kind)
|
|
424
|
+
state.record.updatedAt = meta.createdAt
|
|
425
|
+
return structuredClone(meta)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async getArtifact(slug: string, kind: string, rev?: number): Promise<Artifact | null> {
|
|
429
|
+
const state = this.state(slug)
|
|
430
|
+
const revs = state.artifacts.get(kind)
|
|
431
|
+
if (!revs || revs.length === 0) return null
|
|
432
|
+
const meta =
|
|
433
|
+
rev === undefined ? revs.at(-1) : revs.find((candidate) => candidate.revision === rev)
|
|
434
|
+
if (!meta) return null
|
|
435
|
+
const content = await this.blobs.get(meta.blobRef)
|
|
436
|
+
if (!content) return null
|
|
437
|
+
return { meta: structuredClone(meta), content }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async listArtifacts(slug: string, kind?: string): Promise<ArtifactMeta[]> {
|
|
441
|
+
const state = this.state(slug)
|
|
442
|
+
const all = [...state.artifacts.values()].flat()
|
|
443
|
+
const filtered = kind ? all.filter((meta) => meta.kind === kind) : all
|
|
444
|
+
return structuredClone(
|
|
445
|
+
filtered.sort((a, b) => a.kind.localeCompare(b.kind) || a.revision - b.revision),
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
450
|
+
const state = this.state(slug)
|
|
451
|
+
const now = this.clock().getTime()
|
|
452
|
+
const lease = state.lease
|
|
453
|
+
if (lease && lease.holder !== holder && lease.expiresAt > now) {
|
|
454
|
+
return false
|
|
455
|
+
}
|
|
456
|
+
state.lease = { holder, expiresAt: now + ttlMs, ttlMs }
|
|
457
|
+
state.record.updatedAt = new Date(now).toISOString()
|
|
458
|
+
return true
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async heartbeat(slug: string, holder: string): Promise<boolean> {
|
|
462
|
+
const state = this.state(slug)
|
|
463
|
+
const now = this.clock().getTime()
|
|
464
|
+
const lease = state.lease
|
|
465
|
+
if (!lease || lease.holder !== holder || lease.expiresAt <= now) {
|
|
466
|
+
return false
|
|
467
|
+
}
|
|
468
|
+
lease.expiresAt = now + lease.ttlMs
|
|
469
|
+
state.record.heartbeatAt = new Date(now).toISOString()
|
|
470
|
+
state.record.updatedAt = state.record.heartbeatAt
|
|
471
|
+
return true
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async releaseLease(slug: string, holder: string): Promise<void> {
|
|
475
|
+
const state = this.state(slug)
|
|
476
|
+
if (state.lease?.holder === holder) {
|
|
477
|
+
state.lease = undefined
|
|
478
|
+
state.record.updatedAt = this.now()
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
subscribe(slug: string, opts: SubscribeOptions, onEvent: (event: AbEvent) => void): Unsubscribe {
|
|
483
|
+
return pollingSubscribe((since) => this.getEvents(slug, since), opts, onEvent)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private repoState(repo: string): RepoState {
|
|
487
|
+
const state = this.repos.get(repo)
|
|
488
|
+
if (!state) throw new Error(`unknown repo "${repo}"`)
|
|
489
|
+
return state
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private repoSnapshot(state: RepoState): RepositoryRecord {
|
|
493
|
+
return {
|
|
494
|
+
repo: state.record.repo,
|
|
495
|
+
createdAt: state.record.createdAt,
|
|
496
|
+
updatedAt: state.record.updatedAt,
|
|
497
|
+
...(state.record.heartbeatAt ? { heartbeatAt: state.record.heartbeatAt } : {}),
|
|
498
|
+
...(state.lease
|
|
499
|
+
? {
|
|
500
|
+
lease: {
|
|
501
|
+
holder: state.lease.holder,
|
|
502
|
+
expiresAt: new Date(state.lease.expiresAt).toISOString(),
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
: {}),
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async ensureRepo(repo: string): Promise<RepositoryRecord> {
|
|
510
|
+
if (!repo) throw new Error('repo is required')
|
|
511
|
+
let state = this.repos.get(repo)
|
|
512
|
+
if (!state) {
|
|
513
|
+
const ts = this.now()
|
|
514
|
+
state = {
|
|
515
|
+
record: { repo, createdAt: ts, updatedAt: ts },
|
|
516
|
+
events: [],
|
|
517
|
+
artifacts: new Map(),
|
|
518
|
+
}
|
|
519
|
+
this.repos.set(repo, state)
|
|
520
|
+
}
|
|
521
|
+
return this.repoSnapshot(state)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async getRepo(repo: string): Promise<RepositoryRecord | null> {
|
|
525
|
+
const state = this.repos.get(repo)
|
|
526
|
+
return state ? this.repoSnapshot(state) : null
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async appendRepo<T extends RepositoryEventType>(
|
|
530
|
+
repo: string,
|
|
531
|
+
event: RepositoryEventWrite<T>,
|
|
532
|
+
): Promise<RepositoryEventEnvelope<T>> {
|
|
533
|
+
const state = this.repoState(repo)
|
|
534
|
+
const validated = validateRepositoryEventWrite(event)
|
|
535
|
+
const envelope = {
|
|
536
|
+
repo,
|
|
537
|
+
seq: state.events.length + 1,
|
|
538
|
+
ts: this.now(),
|
|
539
|
+
actor: validated.actor,
|
|
540
|
+
type: validated.type,
|
|
541
|
+
payload: validated.payload,
|
|
542
|
+
} as RepositoryEventEnvelope<T>
|
|
543
|
+
state.events.push(structuredClone(envelope) as RepositoryEvent)
|
|
544
|
+
state.record.updatedAt = envelope.ts
|
|
545
|
+
return envelope
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async appendRepoWithArtifacts<T extends RepositoryEventType>(
|
|
549
|
+
repo: string,
|
|
550
|
+
artifacts: ArtifactInput[],
|
|
551
|
+
makeEvent: (deposited: RepositoryArtifactMeta[]) => RepositoryEventWrite<T>,
|
|
552
|
+
): Promise<{
|
|
553
|
+
event: RepositoryEventEnvelope<T>
|
|
554
|
+
artifacts: RepositoryArtifactMeta[]
|
|
555
|
+
}> {
|
|
556
|
+
const state = this.repoState(repo)
|
|
557
|
+
const prepared: {
|
|
558
|
+
kind: string
|
|
559
|
+
blobRef: string
|
|
560
|
+
metadata: Record<string, unknown>
|
|
561
|
+
}[] = []
|
|
562
|
+
for (const artifact of artifacts) {
|
|
563
|
+
if (!artifact.kind) throw new Error('artifact kind is required')
|
|
564
|
+
const bytes = toBytes(artifact.content)
|
|
565
|
+
const blobRef = contentHash(bytes)
|
|
566
|
+
await this.blobs.put(blobRef, bytes)
|
|
567
|
+
prepared.push({
|
|
568
|
+
kind: artifact.kind,
|
|
569
|
+
blobRef,
|
|
570
|
+
metadata: structuredClone(artifact.metadata ?? {}),
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
const ts = this.now()
|
|
574
|
+
const nextRev = new Map<string, number>()
|
|
575
|
+
const deposited = prepared.map((item): RepositoryArtifactMeta => {
|
|
576
|
+
const revision =
|
|
577
|
+
nextRev.get(item.kind) ?? MemoryBuildStore.nextRevision(state.artifacts.get(item.kind))
|
|
578
|
+
nextRev.set(item.kind, revision + 1)
|
|
579
|
+
return {
|
|
580
|
+
repo,
|
|
581
|
+
kind: item.kind,
|
|
582
|
+
revision,
|
|
583
|
+
blobRef: item.blobRef,
|
|
584
|
+
metadata: item.metadata,
|
|
585
|
+
createdAt: ts,
|
|
586
|
+
}
|
|
587
|
+
})
|
|
588
|
+
// Ordering invariant (AUT-322): event validation precedes any retention
|
|
589
|
+
// prune — validation runs before the deposit loop below, and the
|
|
590
|
+
// per-kind prune happens inside that loop after the deposit (same shape
|
|
591
|
+
// as `appendWithArtifacts` on the build side).
|
|
592
|
+
const validated = validateRepositoryEventWrite(makeEvent(structuredClone(deposited)))
|
|
593
|
+
for (const meta of deposited) {
|
|
594
|
+
const revisions = state.artifacts.get(meta.kind) ?? []
|
|
595
|
+
revisions.push(meta)
|
|
596
|
+
state.artifacts.set(meta.kind, revisions)
|
|
597
|
+
this.pruneRepoKind(state, meta.kind)
|
|
598
|
+
}
|
|
599
|
+
const envelope = {
|
|
600
|
+
repo,
|
|
601
|
+
seq: state.events.length + 1,
|
|
602
|
+
ts,
|
|
603
|
+
actor: validated.actor,
|
|
604
|
+
type: validated.type,
|
|
605
|
+
payload: validated.payload,
|
|
606
|
+
} as RepositoryEventEnvelope<T>
|
|
607
|
+
state.events.push(structuredClone(envelope) as RepositoryEvent)
|
|
608
|
+
state.record.updatedAt = ts
|
|
609
|
+
return { event: envelope, artifacts: structuredClone(deposited) }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async getRepoEvents(
|
|
613
|
+
repo: string,
|
|
614
|
+
sinceSeq = 0,
|
|
615
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
616
|
+
): Promise<RepositoryEvent[]> {
|
|
617
|
+
return readEventsWithWait({
|
|
618
|
+
read: async () => {
|
|
619
|
+
return structuredClone(
|
|
620
|
+
this.repoState(repo).events.filter((event) => event.seq > sinceSeq),
|
|
621
|
+
) as RepositoryEvent[]
|
|
622
|
+
},
|
|
623
|
+
waitSeconds: opts?.waitSeconds,
|
|
624
|
+
signal: opts?.signal,
|
|
625
|
+
// Hosted budget: held repository-event reads poll at most once per
|
|
626
|
+
// second (AUT-383) — not the 25 ms stream default, which is
|
|
627
|
+
// presentation content's cadence.
|
|
628
|
+
pollMs: EVENT_WAIT_POLL_MS,
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
|
|
633
|
+
const state = this.repoState(repo)
|
|
634
|
+
if (!artifact.kind) throw new Error('artifact kind is required')
|
|
635
|
+
const bytes = toBytes(artifact.content)
|
|
636
|
+
const blobRef = contentHash(bytes)
|
|
637
|
+
await this.blobs.put(blobRef, bytes)
|
|
638
|
+
const revisions = state.artifacts.get(artifact.kind) ?? []
|
|
639
|
+
const meta: RepositoryArtifactMeta = {
|
|
640
|
+
repo,
|
|
641
|
+
kind: artifact.kind,
|
|
642
|
+
revision: MemoryBuildStore.nextRevision(revisions),
|
|
643
|
+
blobRef,
|
|
644
|
+
metadata: structuredClone(artifact.metadata ?? {}),
|
|
645
|
+
createdAt: this.now(),
|
|
646
|
+
}
|
|
647
|
+
revisions.push(meta)
|
|
648
|
+
state.artifacts.set(artifact.kind, revisions)
|
|
649
|
+
this.pruneRepoKind(state, artifact.kind)
|
|
650
|
+
state.record.updatedAt = meta.createdAt
|
|
651
|
+
return structuredClone(meta)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async getRepoArtifact(
|
|
655
|
+
repo: string,
|
|
656
|
+
kind: string,
|
|
657
|
+
rev?: number,
|
|
658
|
+
): Promise<RepositoryArtifact | null> {
|
|
659
|
+
const revisions = this.repoState(repo).artifacts.get(kind)
|
|
660
|
+
if (!revisions || revisions.length === 0) return null
|
|
661
|
+
const meta =
|
|
662
|
+
rev === undefined
|
|
663
|
+
? revisions.at(-1)
|
|
664
|
+
: revisions.find((candidate) => candidate.revision === rev)
|
|
665
|
+
if (!meta) return null
|
|
666
|
+
const content = await this.blobs.get(meta.blobRef)
|
|
667
|
+
return content ? { meta: structuredClone(meta), content } : null
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]> {
|
|
671
|
+
const all = [...this.repoState(repo).artifacts.values()].flat()
|
|
672
|
+
return structuredClone(
|
|
673
|
+
(kind ? all.filter((meta) => meta.kind === kind) : all).sort(
|
|
674
|
+
(a, b) => a.kind.localeCompare(b.kind) || a.revision - b.revision,
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
680
|
+
const state = this.repoState(repo)
|
|
681
|
+
const now = this.clock().getTime()
|
|
682
|
+
if (state.lease && state.lease.holder !== holder && state.lease.expiresAt > now) {
|
|
683
|
+
return false
|
|
684
|
+
}
|
|
685
|
+
state.lease = { holder, expiresAt: now + ttlMs, ttlMs }
|
|
686
|
+
state.record.updatedAt = new Date(now).toISOString()
|
|
687
|
+
return true
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async heartbeatRepo(repo: string, holder: string): Promise<boolean> {
|
|
691
|
+
const state = this.repoState(repo)
|
|
692
|
+
const now = this.clock().getTime()
|
|
693
|
+
const lease = state.lease
|
|
694
|
+
if (!lease || lease.holder !== holder || lease.expiresAt <= now) return false
|
|
695
|
+
lease.expiresAt = now + lease.ttlMs
|
|
696
|
+
state.record.heartbeatAt = new Date(now).toISOString()
|
|
697
|
+
state.record.updatedAt = state.record.heartbeatAt
|
|
698
|
+
return true
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async releaseRepoLease(repo: string, holder: string): Promise<void> {
|
|
702
|
+
const state = this.repoState(repo)
|
|
703
|
+
if (state.lease?.holder === holder) {
|
|
704
|
+
state.lease = undefined
|
|
705
|
+
state.record.updatedAt = this.now()
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── Operator sessions (SPEC §7.1.1 — a third resource kind) ─────────
|
|
710
|
+
|
|
711
|
+
private sessionState(id: string): SessionState {
|
|
712
|
+
const state = this.sessions.get(id)
|
|
713
|
+
if (!state) throw new Error(`unknown session "${id}"`)
|
|
714
|
+
return state
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private sessionSnapshot(state: SessionState): SessionRecord {
|
|
718
|
+
return structuredClone(state.record)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async createSession(input: NewSessionInput): Promise<SessionRecord> {
|
|
722
|
+
const operator = normalizeOperator(input.operator)
|
|
723
|
+
if (!input.repo) throw new Error('repo is required')
|
|
724
|
+
const ts = this.now()
|
|
725
|
+
const id = `os_${crypto.randomUUID()}`
|
|
726
|
+
const state: SessionState = {
|
|
727
|
+
record: {
|
|
728
|
+
id,
|
|
729
|
+
repo: input.repo,
|
|
730
|
+
operator,
|
|
731
|
+
...(input.title !== undefined ? { title: input.title } : {}),
|
|
732
|
+
createdAt: ts,
|
|
733
|
+
updatedAt: ts,
|
|
734
|
+
},
|
|
735
|
+
events: [],
|
|
736
|
+
artifacts: new Map(),
|
|
737
|
+
}
|
|
738
|
+
// The record and its first fact land together: `session.created` (seq 1,
|
|
739
|
+
// actor the operator) is validated and appended in the same synchronous
|
|
740
|
+
// commit as the insert — there is no state where a session exists without
|
|
741
|
+
// its creation fact (D6).
|
|
742
|
+
const validated = validateSessionEventWrite({
|
|
743
|
+
actor: humanActor(operator),
|
|
744
|
+
type: 'session.created',
|
|
745
|
+
payload: input.title !== undefined ? { title: input.title } : {},
|
|
746
|
+
})
|
|
747
|
+
state.events.push({
|
|
748
|
+
session: id,
|
|
749
|
+
seq: 1,
|
|
750
|
+
ts,
|
|
751
|
+
actor: validated.actor,
|
|
752
|
+
type: validated.type,
|
|
753
|
+
payload: validated.payload,
|
|
754
|
+
} as SessionEvent)
|
|
755
|
+
this.sessions.set(id, state)
|
|
756
|
+
return this.sessionSnapshot(state)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async getSession(id: string): Promise<SessionRecord | null> {
|
|
760
|
+
const state = this.sessions.get(id)
|
|
761
|
+
return state ? this.sessionSnapshot(state) : null
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async listSessions(repo: string): Promise<SessionRecord[]> {
|
|
765
|
+
// Pinned tiebreak (store/types.ts): Map iteration is insertion order and
|
|
766
|
+
// Map.set on an existing key preserves position, so insertion order *is*
|
|
767
|
+
// creation order — the store-assigned monotonic creation sequence for
|
|
768
|
+
// free, no counter needed. Sessions are never deleted, so ties never
|
|
769
|
+
// reorder over time.
|
|
770
|
+
return [...this.sessions.values()]
|
|
771
|
+
.filter((state) => state.record.repo === repo)
|
|
772
|
+
.map((state) => this.sessionSnapshot(state))
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async appendSessionEvent<T extends SessionEventType>(
|
|
776
|
+
id: string,
|
|
777
|
+
event: SessionEventWrite<T>,
|
|
778
|
+
): Promise<SessionEventEnvelope<T>> {
|
|
779
|
+
const state = this.sessionState(id)
|
|
780
|
+
const validated = validateSessionEventWrite(event)
|
|
781
|
+
const envelope = {
|
|
782
|
+
session: id,
|
|
783
|
+
seq: state.events.length + 1,
|
|
784
|
+
ts: this.now(),
|
|
785
|
+
actor: validated.actor,
|
|
786
|
+
type: validated.type,
|
|
787
|
+
payload: validated.payload,
|
|
788
|
+
} as SessionEventEnvelope<T>
|
|
789
|
+
state.events.push(structuredClone(envelope) as SessionEvent)
|
|
790
|
+
state.record.updatedAt = envelope.ts
|
|
791
|
+
return envelope
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async getSessionEvents(
|
|
795
|
+
id: string,
|
|
796
|
+
sinceSeq = 0,
|
|
797
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
798
|
+
): Promise<SessionEvent[]> {
|
|
799
|
+
const read = async (): Promise<SessionEvent[]> =>
|
|
800
|
+
structuredClone(this.sessionState(id).events.filter((event) => event.seq > sinceSeq))
|
|
801
|
+
return readEventsWithWait({
|
|
802
|
+
read,
|
|
803
|
+
waitSeconds: opts?.waitSeconds,
|
|
804
|
+
signal: opts?.signal,
|
|
805
|
+
// Hosted budget: held session-event reads poll at most once per second
|
|
806
|
+
// (AUT-383), matching the build and repository event reads — not the
|
|
807
|
+
// 25 ms stream default, which is presentation content's cadence.
|
|
808
|
+
pollMs: EVENT_WAIT_POLL_MS,
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async appendSessionWithArtifacts<T extends SessionEventType>(
|
|
813
|
+
id: string,
|
|
814
|
+
artifacts: ArtifactInput[],
|
|
815
|
+
makeEvent: (deposited: SessionArtifactMeta[]) => SessionEventWrite<T>,
|
|
816
|
+
): Promise<{ event: SessionEventEnvelope<T>; artifacts: SessionArtifactMeta[] }> {
|
|
817
|
+
const state = this.sessionState(id)
|
|
818
|
+
const prepared: {
|
|
819
|
+
kind: string
|
|
820
|
+
blobRef: string
|
|
821
|
+
metadata: Record<string, unknown>
|
|
822
|
+
}[] = []
|
|
823
|
+
for (const artifact of artifacts) {
|
|
824
|
+
if (!artifact.kind) throw new Error('artifact kind is required')
|
|
825
|
+
const bytes = toBytes(artifact.content)
|
|
826
|
+
const blobRef = contentHash(bytes)
|
|
827
|
+
await this.blobs.put(blobRef, bytes)
|
|
828
|
+
prepared.push({
|
|
829
|
+
kind: artifact.kind,
|
|
830
|
+
blobRef,
|
|
831
|
+
metadata: structuredClone(artifact.metadata ?? {}),
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
// Same shape as `appendRepoWithArtifacts`: everything is validated before
|
|
835
|
+
// the first mutation, so the synchronous commit has no rollback path to
|
|
836
|
+
// get wrong (D6) and the validation-before-prune invariant (AUT-322)
|
|
837
|
+
// holds trivially — session artifacts are not retention-managed.
|
|
838
|
+
const ts = this.now()
|
|
839
|
+
const nextRev = new Map<string, number>()
|
|
840
|
+
const deposited = prepared.map((item): SessionArtifactMeta => {
|
|
841
|
+
const revision =
|
|
842
|
+
nextRev.get(item.kind) ?? MemoryBuildStore.nextRevision(state.artifacts.get(item.kind))
|
|
843
|
+
nextRev.set(item.kind, revision + 1)
|
|
844
|
+
return {
|
|
845
|
+
session: id,
|
|
846
|
+
kind: item.kind,
|
|
847
|
+
revision,
|
|
848
|
+
blobRef: item.blobRef,
|
|
849
|
+
metadata: item.metadata,
|
|
850
|
+
createdAt: ts,
|
|
851
|
+
}
|
|
852
|
+
})
|
|
853
|
+
const validated = validateSessionEventWrite(makeEvent(structuredClone(deposited)))
|
|
854
|
+
for (const meta of deposited) {
|
|
855
|
+
const revisions = state.artifacts.get(meta.kind) ?? []
|
|
856
|
+
revisions.push(meta)
|
|
857
|
+
state.artifacts.set(meta.kind, revisions)
|
|
858
|
+
}
|
|
859
|
+
const envelope = {
|
|
860
|
+
session: id,
|
|
861
|
+
seq: state.events.length + 1,
|
|
862
|
+
ts,
|
|
863
|
+
actor: validated.actor,
|
|
864
|
+
type: validated.type,
|
|
865
|
+
payload: validated.payload,
|
|
866
|
+
} as SessionEventEnvelope<T>
|
|
867
|
+
state.events.push(structuredClone(envelope) as SessionEvent)
|
|
868
|
+
state.record.updatedAt = ts
|
|
869
|
+
return { event: envelope, artifacts: structuredClone(deposited) }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async putSessionArtifact(id: string, artifact: ArtifactInput): Promise<SessionArtifactMeta> {
|
|
873
|
+
const state = this.sessionState(id)
|
|
874
|
+
if (!artifact.kind) throw new Error('artifact kind is required')
|
|
875
|
+
const bytes = toBytes(artifact.content)
|
|
876
|
+
const blobRef = contentHash(bytes)
|
|
877
|
+
await this.blobs.put(blobRef, bytes)
|
|
878
|
+
const revisions = state.artifacts.get(artifact.kind) ?? []
|
|
879
|
+
const meta: SessionArtifactMeta = {
|
|
880
|
+
session: id,
|
|
881
|
+
kind: artifact.kind,
|
|
882
|
+
revision: MemoryBuildStore.nextRevision(revisions),
|
|
883
|
+
blobRef,
|
|
884
|
+
metadata: structuredClone(artifact.metadata ?? {}),
|
|
885
|
+
createdAt: this.now(),
|
|
886
|
+
}
|
|
887
|
+
revisions.push(meta)
|
|
888
|
+
state.artifacts.set(artifact.kind, revisions)
|
|
889
|
+
state.record.updatedAt = meta.createdAt
|
|
890
|
+
return structuredClone(meta)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async getSessionArtifact(
|
|
894
|
+
id: string,
|
|
895
|
+
kind: string,
|
|
896
|
+
rev?: number,
|
|
897
|
+
): Promise<SessionArtifact | null> {
|
|
898
|
+
const revisions = this.sessionState(id).artifacts.get(kind)
|
|
899
|
+
if (!revisions || revisions.length === 0) return null
|
|
900
|
+
const meta =
|
|
901
|
+
rev === undefined
|
|
902
|
+
? revisions.at(-1)
|
|
903
|
+
: revisions.find((candidate) => candidate.revision === rev)
|
|
904
|
+
if (!meta) return null
|
|
905
|
+
const content = await this.blobs.get(meta.blobRef)
|
|
906
|
+
return content ? { meta: structuredClone(meta), content } : null
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async listSessionArtifacts(id: string, kind?: string): Promise<SessionArtifactMeta[]> {
|
|
910
|
+
const all = [...this.sessionState(id).artifacts.values()].flat()
|
|
911
|
+
return structuredClone(
|
|
912
|
+
(kind ? all.filter((meta) => meta.kind === kind) : all).sort(
|
|
913
|
+
(a, b) => a.kind.localeCompare(b.kind) || a.revision - b.revision,
|
|
914
|
+
),
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── Streams (SPEC §7.6 — the third primitive) ───────────────────────────
|
|
919
|
+
|
|
920
|
+
private streamState(streamId: string): StreamState {
|
|
921
|
+
const state = this.streams.get(streamId)
|
|
922
|
+
if (!state) throw new Error(`unknown stream "${streamId}"`)
|
|
923
|
+
return state
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private snapshotStream(state: StreamState): StreamRecord {
|
|
927
|
+
return structuredClone(state.record)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Stream-chunk retention (SPEC §7.6, deposit-path and count-based like
|
|
931
|
+
* artifact retention): at create, drop the chunks of every previously
|
|
932
|
+
* closed stream in the same scope except the most recently closed one.
|
|
933
|
+
* Records and finalized artifacts are never touched; open streams are
|
|
934
|
+
* never pruned. Runs in the same synchronous commit as the create. */
|
|
935
|
+
private pruneStreamChunksInCommit(scope: StreamScope): void {
|
|
936
|
+
const inScope = [...this.streams.values()].filter(
|
|
937
|
+
(state) =>
|
|
938
|
+
state.record.status === 'closed' &&
|
|
939
|
+
state.record.scope.kind === scope.kind &&
|
|
940
|
+
(scope.kind === 'build'
|
|
941
|
+
? state.record.scope.kind === 'build' && state.record.scope.build === scope.build
|
|
942
|
+
: scope.kind === 'repo'
|
|
943
|
+
? state.record.scope.kind === 'repo' && state.record.scope.repo === scope.repo
|
|
944
|
+
: state.record.scope.kind === 'session' &&
|
|
945
|
+
state.record.scope.session === scope.session),
|
|
946
|
+
)
|
|
947
|
+
if (inScope.length <= 1) return
|
|
948
|
+
// Most recently closed survives (order by closedAt, tie-break by id).
|
|
949
|
+
const keep = inScope
|
|
950
|
+
.map((state) => state.record)
|
|
951
|
+
.sort(
|
|
952
|
+
(a, b) => (a.closedAt ?? '').localeCompare(b.closedAt ?? '') || a.id.localeCompare(b.id),
|
|
953
|
+
)
|
|
954
|
+
.at(-1)!
|
|
955
|
+
for (const state of inScope) {
|
|
956
|
+
if (state.record.id !== keep.id) state.chunks = []
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async createStream(scope: StreamScope, label: string): Promise<StreamRecord> {
|
|
961
|
+
if (!label) throw new Error('stream label is required')
|
|
962
|
+
if (scope.kind === 'build') this.state(scope.build)
|
|
963
|
+
else if (scope.kind === 'repo') this.repoState(scope.repo)
|
|
964
|
+
else this.sessionState(scope.session)
|
|
965
|
+
const id = `st_${crypto.randomUUID()}`
|
|
966
|
+
const record: StreamRecord = {
|
|
967
|
+
id,
|
|
968
|
+
scope: structuredClone(scope),
|
|
969
|
+
label,
|
|
970
|
+
format: STREAM_FORMAT,
|
|
971
|
+
status: 'open',
|
|
972
|
+
createdAt: this.now(),
|
|
973
|
+
}
|
|
974
|
+
// Synchronous commit: the retention prune and the insert are one step.
|
|
975
|
+
this.pruneStreamChunksInCommit(record.scope)
|
|
976
|
+
this.streams.set(id, { record, chunks: [] })
|
|
977
|
+
return this.snapshotStream(this.streamState(id))
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async appendStreamParts(streamId: string, parts: StreamPart[]): Promise<StreamChunk> {
|
|
981
|
+
// Validate and size-check before taking the per-stream lock, so invalid
|
|
982
|
+
// input keeps its current error precedence (AUT-348).
|
|
983
|
+
validateStreamParts(parts)
|
|
984
|
+
const bytes = serializedBatchSize(parts)
|
|
985
|
+
if (bytes > STREAM_BATCH_MAX_BYTES) throw new StreamBatchTooLargeError(bytes)
|
|
986
|
+
// The per-stream lock covers the closed check and the mutation as one
|
|
987
|
+
// critical section: an append issued during a close waits for the close
|
|
988
|
+
// to commit, then fails the status check with an explicit
|
|
989
|
+
// StreamClosedError instead of being silently omitted from the artifact.
|
|
990
|
+
return this.streamLocks.run(streamId, () => {
|
|
991
|
+
const state = this.streamState(streamId)
|
|
992
|
+
if (state.record.status === 'closed') throw new StreamClosedError(streamId)
|
|
993
|
+
const chunk: StreamChunk = {
|
|
994
|
+
stream: streamId,
|
|
995
|
+
seq: state.chunks.length + 1,
|
|
996
|
+
ts: this.now(),
|
|
997
|
+
parts: structuredClone(parts),
|
|
998
|
+
}
|
|
999
|
+
state.chunks.push(chunk)
|
|
1000
|
+
return structuredClone(chunk)
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async readStream(
|
|
1005
|
+
streamId: string,
|
|
1006
|
+
opts?: { since?: number; waitSeconds?: number; signal?: AbortSignal },
|
|
1007
|
+
): Promise<StreamRead> {
|
|
1008
|
+
const read = async (): Promise<StreamRead> => {
|
|
1009
|
+
const state = this.streamState(streamId)
|
|
1010
|
+
const record = state.record
|
|
1011
|
+
return {
|
|
1012
|
+
chunks: structuredClone(state.chunks.filter((chunk) => chunk.seq > (opts?.since ?? 0))),
|
|
1013
|
+
status: record.status,
|
|
1014
|
+
...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
|
|
1015
|
+
...(record.artifact !== undefined ? { artifact: structuredClone(record.artifact) } : {}),
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return readStreamWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord> {
|
|
1022
|
+
// The per-stream lock covers prepare and commit as one critical section
|
|
1023
|
+
// (AUT-348): the commit is synchronous, so with the lock held, no in-
|
|
1024
|
+
// process append can land inside the prepare window — one that waits
|
|
1025
|
+
// instead rejects on the closed check below.
|
|
1026
|
+
return this.streamLocks.run(streamId, async () => {
|
|
1027
|
+
const state = this.streamState(streamId)
|
|
1028
|
+
if (state.record.status === 'closed') return this.snapshotStream(state)
|
|
1029
|
+
// Prepare phase — assemble and store the blob before touching stream
|
|
1030
|
+
// state (mirrors appendWithArtifacts: content-addressed orphans are
|
|
1031
|
+
// harmless; a deposit failure leaves the stream open and unwritten).
|
|
1032
|
+
const { document, droppedPartCount } = await assembleUIMessageDocument(
|
|
1033
|
+
structuredClone(state.chunks.flatMap((chunk) => chunk.parts)),
|
|
1034
|
+
)
|
|
1035
|
+
const ts = this.now()
|
|
1036
|
+
const record = structuredClone(state.record)
|
|
1037
|
+
const input = streamArtifactInput(
|
|
1038
|
+
record.id,
|
|
1039
|
+
record.scope,
|
|
1040
|
+
record.label,
|
|
1041
|
+
outcome,
|
|
1042
|
+
document,
|
|
1043
|
+
state.chunks.length,
|
|
1044
|
+
droppedPartCount,
|
|
1045
|
+
)
|
|
1046
|
+
const bytes = toBytes(input.content)
|
|
1047
|
+
const blobRef = contentHash(bytes)
|
|
1048
|
+
await this.blobs.put(blobRef, bytes)
|
|
1049
|
+
// Commit phase — fully synchronous (no await), so no interleaved writer
|
|
1050
|
+
// can slip between the artifact deposit and the close landing. A close
|
|
1051
|
+
// that raced us through the prepare phase wins; ours is the no-op.
|
|
1052
|
+
const closed: StreamRecord = {
|
|
1053
|
+
...record,
|
|
1054
|
+
status: 'closed',
|
|
1055
|
+
closedAt: ts,
|
|
1056
|
+
outcome,
|
|
1057
|
+
artifact: { kind: input.kind, revision: 0, blobRef },
|
|
1058
|
+
}
|
|
1059
|
+
// A close that raced us through the prepare phase wins; ours is the no-op.
|
|
1060
|
+
const fresh = this.streams.get(streamId)
|
|
1061
|
+
if (fresh && fresh.record.status === 'closed') return this.snapshotStream(fresh)
|
|
1062
|
+
state.record = closed
|
|
1063
|
+
if (record.scope.kind === 'build') {
|
|
1064
|
+
const meta: ArtifactMeta = {
|
|
1065
|
+
build: record.scope.build,
|
|
1066
|
+
kind: input.kind,
|
|
1067
|
+
revision: 0,
|
|
1068
|
+
blobRef,
|
|
1069
|
+
metadata: structuredClone(input.metadata),
|
|
1070
|
+
createdAt: ts,
|
|
1071
|
+
}
|
|
1072
|
+
const buildState = this.state(record.scope.build)
|
|
1073
|
+
const revs = buildState.artifacts.get(input.kind) ?? []
|
|
1074
|
+
revs.push(meta)
|
|
1075
|
+
buildState.artifacts.set(input.kind, revs)
|
|
1076
|
+
} else if (record.scope.kind === 'repo') {
|
|
1077
|
+
const repoMeta: RepositoryArtifactMeta = {
|
|
1078
|
+
repo: record.scope.repo,
|
|
1079
|
+
kind: input.kind,
|
|
1080
|
+
revision: 0,
|
|
1081
|
+
blobRef,
|
|
1082
|
+
metadata: structuredClone(input.metadata),
|
|
1083
|
+
createdAt: ts,
|
|
1084
|
+
}
|
|
1085
|
+
const repoState = this.repoState(record.scope.repo)
|
|
1086
|
+
const revs = repoState.artifacts.get(input.kind) ?? []
|
|
1087
|
+
revs.push(repoMeta)
|
|
1088
|
+
repoState.artifacts.set(input.kind, revs)
|
|
1089
|
+
} else {
|
|
1090
|
+
const sessionMeta: SessionArtifactMeta = {
|
|
1091
|
+
session: record.scope.session,
|
|
1092
|
+
kind: input.kind,
|
|
1093
|
+
revision: 0,
|
|
1094
|
+
blobRef,
|
|
1095
|
+
metadata: structuredClone(input.metadata),
|
|
1096
|
+
createdAt: ts,
|
|
1097
|
+
}
|
|
1098
|
+
const sessionState = this.sessionState(record.scope.session)
|
|
1099
|
+
const revs = sessionState.artifacts.get(input.kind) ?? []
|
|
1100
|
+
revs.push(sessionMeta)
|
|
1101
|
+
sessionState.artifacts.set(input.kind, revs)
|
|
1102
|
+
}
|
|
1103
|
+
return this.snapshotStream(state)
|
|
1104
|
+
})
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async getStream(streamId: string): Promise<StreamRecord | null> {
|
|
1108
|
+
const state = this.streams.get(streamId)
|
|
1109
|
+
return state ? this.snapshotStream(state) : null
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async listStreams(scope: StreamScope): Promise<StreamRecord[]> {
|
|
1113
|
+
// A list of nothing is nothing: an unknown scope lists empty (only
|
|
1114
|
+
// createStream requires the scope's resource to exist).
|
|
1115
|
+
const records = [...this.streams.values()]
|
|
1116
|
+
.filter(
|
|
1117
|
+
(state) =>
|
|
1118
|
+
state.record.scope.kind === scope.kind &&
|
|
1119
|
+
(scope.kind === 'build'
|
|
1120
|
+
? state.record.scope.kind === 'build' && state.record.scope.build === scope.build
|
|
1121
|
+
: scope.kind === 'repo'
|
|
1122
|
+
? state.record.scope.kind === 'repo' && state.record.scope.repo === scope.repo
|
|
1123
|
+
: state.record.scope.kind === 'session' &&
|
|
1124
|
+
state.record.scope.session === scope.session),
|
|
1125
|
+
)
|
|
1126
|
+
.map((state) => this.snapshotStream(state))
|
|
1127
|
+
// Pinned tiebreak (store/types.ts): Map iteration is insertion order and
|
|
1128
|
+
// Map.set on an existing key preserves position, so insertion order *is*
|
|
1129
|
+
// creation order — the store-assigned monotonic creation sequence for
|
|
1130
|
+
// free, no counter needed. Streams are never deleted (retention prunes
|
|
1131
|
+
// stream_chunks only), so ties never reorder over time.
|
|
1132
|
+
return records
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async close(): Promise<void> {
|
|
1136
|
+
// Nothing to release.
|
|
1137
|
+
}
|
|
1138
|
+
}
|