@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,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC-scoped store tokens (SPEC §8.1 [D8]): "the token is scoped to this
|
|
3
|
+
* build and session — an agent physically cannot append to another build's
|
|
4
|
+
* log or read another build's artifacts". Least privilege comes from the
|
|
5
|
+
* runner minting a narrow token, not from prompt instructions.
|
|
6
|
+
*
|
|
7
|
+
* A token is `base64url(JSON scope) + '.' + base64url(hmac-sha256(secret,
|
|
8
|
+
* payload))`. The scope IS the token — the server keeps no session state;
|
|
9
|
+
* it verifies the signature (timing-safe) and the expiry, then trusts the
|
|
10
|
+
* embedded scope.
|
|
11
|
+
*/
|
|
12
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import { viaSchema, type Via } from '../../events/envelope'
|
|
15
|
+
|
|
16
|
+
export interface LegacyBuildTokenScope {
|
|
17
|
+
/** Retained wire compatibility for all existing build-scoped tokens. */
|
|
18
|
+
build: string
|
|
19
|
+
session: string
|
|
20
|
+
exp: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ResourceTokenScope {
|
|
24
|
+
resource: {
|
|
25
|
+
kind: 'build' | 'repo' | 'session'
|
|
26
|
+
id: string
|
|
27
|
+
}
|
|
28
|
+
session: string
|
|
29
|
+
exp: number
|
|
30
|
+
/** Delegated-write attribution (§15.1): when present, the hosted service
|
|
31
|
+
* stamps this via onto human-actor event writes and rejects writes claiming
|
|
32
|
+
* a via the token does not carry. A session resource token's authority is
|
|
33
|
+
* exactly its one session. */
|
|
34
|
+
via?: Via
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Human operator authority is deliberately disjoint from raw store authority.
|
|
38
|
+
* The signed nonblank user claim attributes every operator API event. An
|
|
39
|
+
* unscoped operator token carries no via claim, so any write claiming a via
|
|
40
|
+
* under it is rejected. */
|
|
41
|
+
export interface OperatorTokenScope {
|
|
42
|
+
operator: { user: string }
|
|
43
|
+
exp: number
|
|
44
|
+
via?: Via
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Deployment authority used by dispatchers for raw store and hosted ticket
|
|
48
|
+
* routes. It intentionally does not grant access to attributed operator APIs. */
|
|
49
|
+
export interface DeploymentTokenScope {
|
|
50
|
+
operator: true
|
|
51
|
+
session: string
|
|
52
|
+
exp: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Legacy scopes remain valid on their existing routes. */
|
|
56
|
+
export type TokenScope =
|
|
57
|
+
| LegacyBuildTokenScope
|
|
58
|
+
| ResourceTokenScope
|
|
59
|
+
| OperatorTokenScope
|
|
60
|
+
| DeploymentTokenScope
|
|
61
|
+
|
|
62
|
+
const commonScope = {
|
|
63
|
+
session: z.string().min(1),
|
|
64
|
+
exp: z.number().int(),
|
|
65
|
+
}
|
|
66
|
+
const tokenScopeSchema = z.union([
|
|
67
|
+
z.strictObject({ build: z.string().min(1), ...commonScope }),
|
|
68
|
+
z.strictObject({
|
|
69
|
+
resource: z.strictObject({
|
|
70
|
+
kind: z.enum(['build', 'repo', 'session']),
|
|
71
|
+
id: z.string().min(1),
|
|
72
|
+
}),
|
|
73
|
+
...commonScope,
|
|
74
|
+
via: viaSchema.optional(),
|
|
75
|
+
}),
|
|
76
|
+
z.strictObject({
|
|
77
|
+
operator: z.strictObject({ user: z.string().trim().min(1) }),
|
|
78
|
+
exp: z.number().int(),
|
|
79
|
+
via: viaSchema.optional(),
|
|
80
|
+
}),
|
|
81
|
+
z.strictObject({ operator: z.literal(true), ...commonScope }),
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
export function tokenResource(scope: TokenScope): {
|
|
85
|
+
kind: 'build' | 'repo' | 'session' | 'admin' | 'operator' | 'deployment'
|
|
86
|
+
id: string
|
|
87
|
+
} {
|
|
88
|
+
if ('operator' in scope) {
|
|
89
|
+
return scope.operator === true
|
|
90
|
+
? { kind: 'deployment', id: '*' }
|
|
91
|
+
: { kind: 'operator', id: scope.operator.user }
|
|
92
|
+
}
|
|
93
|
+
if ('build' in scope) {
|
|
94
|
+
return scope.build === '*' ? { kind: 'admin', id: '*' } : { kind: 'build', id: scope.build }
|
|
95
|
+
}
|
|
96
|
+
return scope.resource
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sign(secret: string, payload: string): Buffer {
|
|
100
|
+
return createHmac('sha256', secret).update(payload).digest()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function mintToken(secret: string, scope: TokenScope): string {
|
|
104
|
+
const payload = Buffer.from(JSON.stringify(scope), 'utf8').toString('base64url')
|
|
105
|
+
return `${payload}.${sign(secret, payload).toString('base64url')}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Bad signature, malformed token, unparseable scope, or expired → null —
|
|
110
|
+
* verification never explains itself to the caller (the server maps null to
|
|
111
|
+
* 401). The signature compare is timing-safe via `crypto.timingSafeEqual`.
|
|
112
|
+
*/
|
|
113
|
+
export function verifyToken(secret: string, token: string, now: Date): TokenScope | null {
|
|
114
|
+
const parts = token.split('.')
|
|
115
|
+
if (parts.length !== 2) return null
|
|
116
|
+
const [payload, signature] = parts as [string, string]
|
|
117
|
+
const expected = sign(secret, payload)
|
|
118
|
+
let provided: Buffer
|
|
119
|
+
try {
|
|
120
|
+
provided = Buffer.from(signature, 'base64url')
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
let raw: unknown
|
|
128
|
+
try {
|
|
129
|
+
raw = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
const scope = tokenScopeSchema.safeParse(raw)
|
|
134
|
+
if (!scope.success) return null
|
|
135
|
+
if (scope.data.exp <= now.getTime()) return null
|
|
136
|
+
return scope.data
|
|
137
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import manifest from '../../../../../package.json'
|
|
2
|
+
|
|
3
|
+
/** Release-synchronized identity sent by every remote-store client. */
|
|
4
|
+
export const AUTOBUILD_VERSION = manifest.version
|
|
5
|
+
|
|
6
|
+
/** Wire compatibility version for the remote BuildStore protocol. */
|
|
7
|
+
export const REMOTE_STORE_PROTOCOL_VERSION = '2'
|
|
8
|
+
|
|
9
|
+
export const AUTOBUILD_VERSION_HEADER = 'x-autobuild-version'
|
|
10
|
+
export const REMOTE_STORE_PROTOCOL_VERSION_HEADER = 'x-autobuild-protocol-version'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact retention policy for the dispatcher run/config artifact family.
|
|
3
|
+
*
|
|
4
|
+
* Every dispatcher run start and config reload deposits a
|
|
5
|
+
* `dispatcher-effective-config` (and on reload a `dispatcher-config`)
|
|
6
|
+
* repository artifact; each build launch deposits a
|
|
7
|
+
* `build-runner-effective-config` build artifact. Without a bound these rows
|
|
8
|
+
* grow without limit (~1440/day/repo for a per-minute hosted dispatcher cron).
|
|
9
|
+
*
|
|
10
|
+
* The store contract (`store/types.ts`) has no delete, and the remote-store
|
|
11
|
+
* protocol deliberately exposes no deletion endpoint — the artifact-store
|
|
12
|
+
* schema and transport are out of bounds for retention. The policy is
|
|
13
|
+
* therefore enforced **inside each store adapter at the deposit path**: when a
|
|
14
|
+
* new revision of a retention-managed kind is deposited, the adapter keeps the
|
|
15
|
+
* latest `maxRevisions` revisions of that `(scope, kind)` and deletes the
|
|
16
|
+
* older ones in the same transaction, under the same serialization as the
|
|
17
|
+
* deposit itself.
|
|
18
|
+
*
|
|
19
|
+
* The rule is count-based, not age-based: revision sequences already order by
|
|
20
|
+
* deposit recency, so recency is preserved without a clock in the decision,
|
|
21
|
+
* the boundary is deterministic, and an age rule would still only ever fire on
|
|
22
|
+
* a deposit (there is no sweeper). Events and journal entries are never
|
|
23
|
+
* pruned — historical payloads may reference a revision that no longer
|
|
24
|
+
* resolves; the current run's snapshot is always among the newest deposits.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Operator notes artifact kind (`operator/registry.ts`). The agent-facing
|
|
29
|
+
* notes tools read and write this repository artifact kind; it is
|
|
30
|
+
* retention-managed so an operator agent that revises its notes on every
|
|
31
|
+
* round cannot grow the repository artifact table without limit. The bound
|
|
32
|
+
* is the store's configured `maxRevisions` — the same deposit-time prune
|
|
33
|
+
* the dispatcher config family uses — whose default is
|
|
34
|
+
* `DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS` (the newest 200 revisions
|
|
35
|
+
* survive). Defined here rather than in the registry module for the same
|
|
36
|
+
* reason the dispatcher kinds are: store adapters must not import from the
|
|
37
|
+
* operator surface (store → operator would be an import cycle).
|
|
38
|
+
*/
|
|
39
|
+
export const OPERATOR_NOTES_REPO_KIND = 'operator-notes'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Repository-scoped artifact kinds subject to retention. Values mirror the
|
|
43
|
+
* kind constants in `cli/dispatch.ts` (DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT)
|
|
44
|
+
* and `config/live.ts` (DISPATCHER_CONFIG_ARTIFACT); they are restated as
|
|
45
|
+
* literals here because the store adapters must not import from the CLI
|
|
46
|
+
* (store → cli would be an import cycle).
|
|
47
|
+
*/
|
|
48
|
+
export const DISPATCHER_RETENTION_REPO_KINDS = [
|
|
49
|
+
'dispatcher-effective-config',
|
|
50
|
+
'dispatcher-config',
|
|
51
|
+
] as const
|
|
52
|
+
|
|
53
|
+
/** Repository-scoped kinds retained for the operator surface (see above). */
|
|
54
|
+
export const OPERATOR_RETENTION_REPO_KINDS = [OPERATOR_NOTES_REPO_KIND] as const
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build-scoped artifact kinds subject to retention. Value mirrors
|
|
58
|
+
* `BUILD_EFFECTIVE_CONFIG_ARTIFACT` in `processes/build-execution-state.ts`.
|
|
59
|
+
*/
|
|
60
|
+
export const DISPATCHER_RETENTION_BUILD_KINDS = ['build-runner-effective-config'] as const
|
|
61
|
+
|
|
62
|
+
/** Default bound: the newest 200 revisions of each retention-managed kind survive. */
|
|
63
|
+
export const DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS = 200
|
|
64
|
+
|
|
65
|
+
/** True when deposits of `kind` are retention-managed (pruned past the bound). */
|
|
66
|
+
export function isRetentionManagedKind(kind: string): boolean {
|
|
67
|
+
return (
|
|
68
|
+
(DISPATCHER_RETENTION_REPO_KINDS as readonly string[]).includes(kind) ||
|
|
69
|
+
(OPERATOR_RETENTION_REPO_KINDS as readonly string[]).includes(kind) ||
|
|
70
|
+
(DISPATCHER_RETENTION_BUILD_KINDS as readonly string[]).includes(kind)
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The pure pruning decision every adapter (and every test) shares: given the
|
|
76
|
+
* revision numbers that currently exist for one `(scope, kind)`, return the
|
|
77
|
+
* ones beyond the newest `maxRevisions` — exactly the revisions to delete.
|
|
78
|
+
* Pure and side-effect-free; returns [] when nothing is past the bound.
|
|
79
|
+
*/
|
|
80
|
+
export function revisionsToPrune(revisions: readonly number[], maxRevisions: number): number[] {
|
|
81
|
+
if (!Number.isInteger(maxRevisions) || maxRevisions < 1) {
|
|
82
|
+
throw new Error(`maxRevisions must be a positive integer, got ${maxRevisions}`)
|
|
83
|
+
}
|
|
84
|
+
for (const revision of revisions) {
|
|
85
|
+
if (!Number.isInteger(revision) || revision < 0) {
|
|
86
|
+
throw new Error(`revisions must be nonnegative integers, got ${revision}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const newestFirst = [...revisions].sort((a, b) => b - a)
|
|
90
|
+
return newestFirst.slice(maxRevisions).sort((a, b) => a - b)
|
|
91
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { EventType } from '../events/payloads'
|
|
2
|
+
import type { EventWrite } from '../events/catalog'
|
|
3
|
+
import type { RepositoryEventType, RepositoryEventWrite } from '../events/repository'
|
|
4
|
+
import type { AbEvent, EventEnvelope } from '../events/catalog'
|
|
5
|
+
import type { RepositoryEvent, RepositoryEventEnvelope } from '../events/repository'
|
|
6
|
+
import type {
|
|
7
|
+
SessionEvent,
|
|
8
|
+
SessionEventEnvelope,
|
|
9
|
+
SessionEventType,
|
|
10
|
+
SessionEventWrite,
|
|
11
|
+
} from '../events/sessions'
|
|
12
|
+
import type {
|
|
13
|
+
Artifact,
|
|
14
|
+
ArtifactInput,
|
|
15
|
+
ArtifactMeta,
|
|
16
|
+
BuildRecord,
|
|
17
|
+
BuildStore,
|
|
18
|
+
NewBuildInput,
|
|
19
|
+
RepositoryArtifact,
|
|
20
|
+
RepositoryArtifactMeta,
|
|
21
|
+
RepositoryRecord,
|
|
22
|
+
SessionArtifact,
|
|
23
|
+
SessionArtifactMeta,
|
|
24
|
+
SessionRecord,
|
|
25
|
+
SessionScopedStore,
|
|
26
|
+
SubscribeOptions,
|
|
27
|
+
Unsubscribe,
|
|
28
|
+
} from './types'
|
|
29
|
+
import type {
|
|
30
|
+
StreamChunk,
|
|
31
|
+
StreamOutcome,
|
|
32
|
+
StreamPart,
|
|
33
|
+
StreamRead,
|
|
34
|
+
StreamRecord,
|
|
35
|
+
StreamScope,
|
|
36
|
+
} from './streams/types'
|
|
37
|
+
|
|
38
|
+
/** Interface-level authority failure for a session-scoped handle — the
|
|
39
|
+
* session counterpart of `BuildScopeError`. */
|
|
40
|
+
export class SessionScopeError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
readonly scope: string,
|
|
43
|
+
readonly operation: string,
|
|
44
|
+
readonly target?: string,
|
|
45
|
+
) {
|
|
46
|
+
super(
|
|
47
|
+
target === undefined
|
|
48
|
+
? `session-scoped store for ${JSON.stringify(scope)} forbids ${operation}`
|
|
49
|
+
: `session-scoped store for ${JSON.stringify(scope)} forbids ${operation} targeting ${JSON.stringify(target)}`,
|
|
50
|
+
)
|
|
51
|
+
this.name = 'SessionScopeError'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wrap a full store with one-session authority. The wrapper keeps the
|
|
56
|
+
* complete BuildStore shape so accidental session create/list, collection,
|
|
57
|
+
* build, or repository calls fail loudly instead of disappearing behind a
|
|
58
|
+
* type assertion. A session-scoped handle may touch only its own session's
|
|
59
|
+
* record, events, artifacts, and session-scoped streams. */
|
|
60
|
+
export function createSessionScopedStore(store: BuildStore, scope: string): SessionScopedStore {
|
|
61
|
+
const own = (operation: string, id: string): void => {
|
|
62
|
+
if (id !== scope) throw new SessionScopeError(scope, operation, id)
|
|
63
|
+
}
|
|
64
|
+
const ownStreamScope = (record: StreamRecord): void => {
|
|
65
|
+
if (record.scope.kind !== 'session' || record.scope.session !== scope) {
|
|
66
|
+
throw new SessionScopeError(scope, 'stream', record.id)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const ownStream = async (streamId: string): Promise<void> => {
|
|
70
|
+
const record = await store.getStream(streamId)
|
|
71
|
+
if (record) ownStreamScope(record)
|
|
72
|
+
}
|
|
73
|
+
const sessionScope = (operation: string, candidate: StreamScope): void => {
|
|
74
|
+
if (candidate.kind !== 'session' || candidate.session !== scope) {
|
|
75
|
+
throw new SessionScopeError(scope, operation, JSON.stringify(candidate))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
sessionScope: scope,
|
|
80
|
+
scopeSession(id: string): SessionScopedStore {
|
|
81
|
+
own('scopeSession', id)
|
|
82
|
+
return this
|
|
83
|
+
},
|
|
84
|
+
scopeBuild(_slug: string): never {
|
|
85
|
+
throw new SessionScopeError(scope, 'scopeBuild')
|
|
86
|
+
},
|
|
87
|
+
createBuild(_input: NewBuildInput): Promise<BuildRecord> {
|
|
88
|
+
return Promise.reject(new SessionScopeError(scope, 'createBuild'))
|
|
89
|
+
},
|
|
90
|
+
getBuild(slug: string): Promise<BuildRecord | null> {
|
|
91
|
+
return Promise.reject(new SessionScopeError(scope, 'getBuild', slug))
|
|
92
|
+
},
|
|
93
|
+
listBuilds(): Promise<BuildRecord[]> {
|
|
94
|
+
return Promise.reject(new SessionScopeError(scope, 'listBuilds'))
|
|
95
|
+
},
|
|
96
|
+
append<T extends EventType>(slug: string, _event: EventWrite<T>): Promise<EventEnvelope<T>> {
|
|
97
|
+
return Promise.reject(new SessionScopeError(scope, 'append', slug))
|
|
98
|
+
},
|
|
99
|
+
appendIfCurrent<T extends EventType>(
|
|
100
|
+
slug: string,
|
|
101
|
+
_expectedSeq: number,
|
|
102
|
+
_event: EventWrite<T>,
|
|
103
|
+
): Promise<EventEnvelope<T> | null> {
|
|
104
|
+
return Promise.reject(new SessionScopeError(scope, 'appendIfCurrent', slug))
|
|
105
|
+
},
|
|
106
|
+
appendWithArtifacts<T extends EventType>(
|
|
107
|
+
slug: string,
|
|
108
|
+
_artifacts: ArtifactInput[],
|
|
109
|
+
_makeEvent: (deposited: ArtifactMeta[]) => EventWrite<T>,
|
|
110
|
+
): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
|
|
111
|
+
return Promise.reject(new SessionScopeError(scope, 'appendWithArtifacts', slug))
|
|
112
|
+
},
|
|
113
|
+
getEvents(slug: string, _sinceSeq?: number): Promise<AbEvent[]> {
|
|
114
|
+
return Promise.reject(new SessionScopeError(scope, 'getEvents', slug))
|
|
115
|
+
},
|
|
116
|
+
putArtifact(slug: string, _artifact: ArtifactInput): Promise<ArtifactMeta> {
|
|
117
|
+
return Promise.reject(new SessionScopeError(scope, 'putArtifact', slug))
|
|
118
|
+
},
|
|
119
|
+
getArtifact(slug: string, _kind: string, _rev?: number): Promise<Artifact | null> {
|
|
120
|
+
return Promise.reject(new SessionScopeError(scope, 'getArtifact', slug))
|
|
121
|
+
},
|
|
122
|
+
listArtifacts(slug: string, _kind?: string): Promise<ArtifactMeta[]> {
|
|
123
|
+
return Promise.reject(new SessionScopeError(scope, 'listArtifacts', slug))
|
|
124
|
+
},
|
|
125
|
+
claimLease(slug: string, _holder: string, _ttlMs: number): Promise<boolean> {
|
|
126
|
+
return Promise.reject(new SessionScopeError(scope, 'claimLease', slug))
|
|
127
|
+
},
|
|
128
|
+
heartbeat(slug: string, _holder: string): Promise<boolean> {
|
|
129
|
+
return Promise.reject(new SessionScopeError(scope, 'heartbeat', slug))
|
|
130
|
+
},
|
|
131
|
+
releaseLease(slug: string, _holder: string): Promise<void> {
|
|
132
|
+
return Promise.reject(new SessionScopeError(scope, 'releaseLease', slug))
|
|
133
|
+
},
|
|
134
|
+
subscribe(
|
|
135
|
+
slug: string,
|
|
136
|
+
_opts: SubscribeOptions,
|
|
137
|
+
_onEvent: (event: AbEvent) => void,
|
|
138
|
+
): Unsubscribe {
|
|
139
|
+
throw new SessionScopeError(scope, 'subscribe', slug)
|
|
140
|
+
},
|
|
141
|
+
ensureRepo(repo: string): Promise<RepositoryRecord> {
|
|
142
|
+
return Promise.reject(new SessionScopeError(scope, 'ensureRepo', repo))
|
|
143
|
+
},
|
|
144
|
+
getRepo(repo: string): Promise<RepositoryRecord | null> {
|
|
145
|
+
return Promise.reject(new SessionScopeError(scope, 'getRepo', repo))
|
|
146
|
+
},
|
|
147
|
+
appendRepo<T extends RepositoryEventType>(
|
|
148
|
+
repo: string,
|
|
149
|
+
_event: RepositoryEventWrite<T>,
|
|
150
|
+
): Promise<RepositoryEventEnvelope<T>> {
|
|
151
|
+
return Promise.reject(new SessionScopeError(scope, 'appendRepo', repo))
|
|
152
|
+
},
|
|
153
|
+
appendRepoWithArtifacts<T extends RepositoryEventType>(
|
|
154
|
+
repo: string,
|
|
155
|
+
_artifacts: ArtifactInput[],
|
|
156
|
+
_makeEvent: (deposited: RepositoryArtifactMeta[]) => RepositoryEventWrite<T>,
|
|
157
|
+
): Promise<{ event: RepositoryEventEnvelope<T>; artifacts: RepositoryArtifactMeta[] }> {
|
|
158
|
+
return Promise.reject(new SessionScopeError(scope, 'appendRepoWithArtifacts', repo))
|
|
159
|
+
},
|
|
160
|
+
getRepoEvents(repo: string, _sinceSeq?: number): Promise<RepositoryEvent[]> {
|
|
161
|
+
return Promise.reject(new SessionScopeError(scope, 'getRepoEvents', repo))
|
|
162
|
+
},
|
|
163
|
+
putRepoArtifact(repo: string, _artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
|
|
164
|
+
return Promise.reject(new SessionScopeError(scope, 'putRepoArtifact', repo))
|
|
165
|
+
},
|
|
166
|
+
getRepoArtifact(
|
|
167
|
+
repo: string,
|
|
168
|
+
_kind: string,
|
|
169
|
+
_rev?: number,
|
|
170
|
+
): Promise<RepositoryArtifact | null> {
|
|
171
|
+
return Promise.reject(new SessionScopeError(scope, 'getRepoArtifact', repo))
|
|
172
|
+
},
|
|
173
|
+
listRepoArtifacts(repo: string, _kind?: string): Promise<RepositoryArtifactMeta[]> {
|
|
174
|
+
return Promise.reject(new SessionScopeError(scope, 'listRepoArtifacts', repo))
|
|
175
|
+
},
|
|
176
|
+
claimRepoLease(repo: string, _holder: string, _ttlMs: number): Promise<boolean> {
|
|
177
|
+
return Promise.reject(new SessionScopeError(scope, 'claimRepoLease', repo))
|
|
178
|
+
},
|
|
179
|
+
heartbeatRepo(repo: string, _holder: string): Promise<boolean> {
|
|
180
|
+
return Promise.reject(new SessionScopeError(scope, 'heartbeatRepo', repo))
|
|
181
|
+
},
|
|
182
|
+
releaseRepoLease(repo: string, _holder: string): Promise<void> {
|
|
183
|
+
return Promise.reject(new SessionScopeError(scope, 'releaseRepoLease', repo))
|
|
184
|
+
},
|
|
185
|
+
async createSession(input): Promise<SessionRecord> {
|
|
186
|
+
throw new SessionScopeError(scope, 'createSession', input.repo)
|
|
187
|
+
},
|
|
188
|
+
async getSession(id: string): Promise<SessionRecord | null> {
|
|
189
|
+
own('getSession', id)
|
|
190
|
+
return store.getSession(id)
|
|
191
|
+
},
|
|
192
|
+
listSessions(repo: string): Promise<SessionRecord[]> {
|
|
193
|
+
return Promise.reject(new SessionScopeError(scope, 'listSessions', repo))
|
|
194
|
+
},
|
|
195
|
+
async appendSessionEvent<T extends SessionEventType>(
|
|
196
|
+
id: string,
|
|
197
|
+
event: SessionEventWrite<T>,
|
|
198
|
+
): Promise<SessionEventEnvelope<T>> {
|
|
199
|
+
own('appendSessionEvent', id)
|
|
200
|
+
return store.appendSessionEvent(id, event)
|
|
201
|
+
},
|
|
202
|
+
async getSessionEvents(
|
|
203
|
+
id: string,
|
|
204
|
+
sinceSeq?: number,
|
|
205
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
206
|
+
): Promise<SessionEvent[]> {
|
|
207
|
+
own('getSessionEvents', id)
|
|
208
|
+
return store.getSessionEvents(id, sinceSeq, opts)
|
|
209
|
+
},
|
|
210
|
+
async appendSessionWithArtifacts<T extends SessionEventType>(
|
|
211
|
+
id: string,
|
|
212
|
+
artifacts: ArtifactInput[],
|
|
213
|
+
makeEvent: (deposited: SessionArtifactMeta[]) => SessionEventWrite<T>,
|
|
214
|
+
): Promise<{ event: SessionEventEnvelope<T>; artifacts: SessionArtifactMeta[] }> {
|
|
215
|
+
own('appendSessionWithArtifacts', id)
|
|
216
|
+
return store.appendSessionWithArtifacts(id, artifacts, makeEvent)
|
|
217
|
+
},
|
|
218
|
+
async putSessionArtifact(id: string, artifact: ArtifactInput): Promise<SessionArtifactMeta> {
|
|
219
|
+
own('putSessionArtifact', id)
|
|
220
|
+
return store.putSessionArtifact(id, artifact)
|
|
221
|
+
},
|
|
222
|
+
async getSessionArtifact(
|
|
223
|
+
id: string,
|
|
224
|
+
kind: string,
|
|
225
|
+
rev?: number,
|
|
226
|
+
): Promise<SessionArtifact | null> {
|
|
227
|
+
own('getSessionArtifact', id)
|
|
228
|
+
return store.getSessionArtifact(id, kind, rev)
|
|
229
|
+
},
|
|
230
|
+
async listSessionArtifacts(id: string, kind?: string): Promise<SessionArtifactMeta[]> {
|
|
231
|
+
own('listSessionArtifacts', id)
|
|
232
|
+
return store.listSessionArtifacts(id, kind)
|
|
233
|
+
},
|
|
234
|
+
async createStream(candidate: StreamScope, label: string): Promise<StreamRecord> {
|
|
235
|
+
sessionScope('createStream', candidate)
|
|
236
|
+
return store.createStream(candidate, label)
|
|
237
|
+
},
|
|
238
|
+
async appendStreamParts(streamId: string, parts: StreamPart[]): Promise<StreamChunk> {
|
|
239
|
+
await ownStream(streamId)
|
|
240
|
+
return store.appendStreamParts(streamId, parts)
|
|
241
|
+
},
|
|
242
|
+
async readStream(
|
|
243
|
+
streamId: string,
|
|
244
|
+
opts?: { since?: number; waitSeconds?: number; signal?: AbortSignal },
|
|
245
|
+
): Promise<StreamRead> {
|
|
246
|
+
await ownStream(streamId)
|
|
247
|
+
return store.readStream(streamId, opts)
|
|
248
|
+
},
|
|
249
|
+
async closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord> {
|
|
250
|
+
await ownStream(streamId)
|
|
251
|
+
return store.closeStream(streamId, outcome)
|
|
252
|
+
},
|
|
253
|
+
async getStream(streamId: string): Promise<StreamRecord | null> {
|
|
254
|
+
await ownStream(streamId)
|
|
255
|
+
return store.getStream(streamId)
|
|
256
|
+
},
|
|
257
|
+
async listStreams(candidate: StreamScope): Promise<StreamRecord[]> {
|
|
258
|
+
sessionScope('listStreams', candidate)
|
|
259
|
+
return store.listStreams(candidate)
|
|
260
|
+
},
|
|
261
|
+
close(): Promise<void> {
|
|
262
|
+
return Promise.reject(new SessionScopeError(scope, 'close'))
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Close-time assembly (SPEC §7.6): the one piece of shared core code every
|
|
3
|
+
* adapter uses to turn a stream's part sequence into the protocol's
|
|
4
|
+
* `UIMessage[]` document.
|
|
5
|
+
*
|
|
6
|
+
* The SDK's own processor is the protocol: classification reuses
|
|
7
|
+
* `uiMessageChunkSchema` (the executable definition of "defined part type")
|
|
8
|
+
* and assembly reuses `readUIMessageStream`. Parts of undefined types — and
|
|
9
|
+
* malformed defined-type parts — are dropped and counted, never fatal.
|
|
10
|
+
*
|
|
11
|
+
* The SDK's stream state machine models exactly one message per stream (a
|
|
12
|
+
* `start` chunk updates the message id/metadata without resetting accumulated
|
|
13
|
+
* parts), while a stream here is a whole session. The part sequence is
|
|
14
|
+
* therefore split into segments at each `start` chunk, and one `UIMessage`
|
|
15
|
+
* per segment is collected, in order. Verified against ai@7 behavior by
|
|
16
|
+
* `assemble.test.ts`, which is the tripwire if an SDK upgrade changes it.
|
|
17
|
+
*/
|
|
18
|
+
import { readUIMessageStream, uiMessageChunkSchema } from 'ai'
|
|
19
|
+
import type { UIMessage } from 'ai'
|
|
20
|
+
import type { StreamPart } from './types'
|
|
21
|
+
|
|
22
|
+
const chunkSchema = uiMessageChunkSchema() as unknown as {
|
|
23
|
+
validate: (value: unknown) => Promise<{ success: boolean }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Classify one part against the protocol's defined chunk union. */
|
|
27
|
+
async function isDefinedPart(part: StreamPart): Promise<boolean> {
|
|
28
|
+
return (await chunkSchema.validate(part)).success
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Assemble the ordered part sequence of a whole stream into the document.
|
|
33
|
+
* Returns the `UIMessage[]` (empty when nothing assembled, e.g. zero chunks)
|
|
34
|
+
* and the count of dropped parts (undefined types and malformed defined-type
|
|
35
|
+
* parts). Per-chunk protocol errors surface through the SDK's `onError` and
|
|
36
|
+
* are swallowed — an `error` chunk is content, not an assembly failure.
|
|
37
|
+
*/
|
|
38
|
+
export async function assembleUIMessageDocument(
|
|
39
|
+
parts: StreamPart[],
|
|
40
|
+
): Promise<{ document: UIMessage[]; droppedPartCount: number }> {
|
|
41
|
+
const defined: StreamPart[] = []
|
|
42
|
+
let droppedPartCount = 0
|
|
43
|
+
for (const part of parts) {
|
|
44
|
+
if (await isDefinedPart(part)) {
|
|
45
|
+
defined.push(part)
|
|
46
|
+
} else {
|
|
47
|
+
droppedPartCount++
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Split into segments at each `start` chunk (a segment begins with its
|
|
52
|
+
// `start`); parts before the first `start` form a leading segment.
|
|
53
|
+
const segments: StreamPart[][] = []
|
|
54
|
+
let current: StreamPart[] = []
|
|
55
|
+
for (const part of defined) {
|
|
56
|
+
if (part.type === 'start' && current.length > 0) {
|
|
57
|
+
segments.push(current)
|
|
58
|
+
current = []
|
|
59
|
+
}
|
|
60
|
+
current.push(part)
|
|
61
|
+
}
|
|
62
|
+
if (current.length > 0) segments.push(current)
|
|
63
|
+
|
|
64
|
+
const document: UIMessage[] = []
|
|
65
|
+
for (const segment of segments) {
|
|
66
|
+
const stream = new ReadableStream<StreamPart>({
|
|
67
|
+
start(controller) {
|
|
68
|
+
for (const part of segment) controller.enqueue(part)
|
|
69
|
+
controller.close()
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
let last: UIMessage | undefined
|
|
73
|
+
for await (const message of readUIMessageStream({ stream: stream as never })) {
|
|
74
|
+
last = message
|
|
75
|
+
}
|
|
76
|
+
if (last) document.push(last)
|
|
77
|
+
}
|
|
78
|
+
return { document, droppedPartCount }
|
|
79
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-stream, in-process async mutex (AUT-348): a promise-chain per key that
|
|
3
|
+
* serializes a store's close against its appends inside one process, so an
|
|
4
|
+
* append issued while a close is in flight either lands before the close's
|
|
5
|
+
* prepare snapshot or blocks until the close commits and then fails the
|
|
6
|
+
* closed re-check with an explicit error — never a silently omitted chunk.
|
|
7
|
+
*
|
|
8
|
+
* This is coordination *within one process only*. Cross-process and
|
|
9
|
+
* cross-connection writers are invisible to it; serializing those remains the
|
|
10
|
+
* store adapter's transaction discipline (BEGIN IMMEDIATE re-verification in
|
|
11
|
+
* SQLite, the `FOR UPDATE` row lock in Postgres).
|
|
12
|
+
*/
|
|
13
|
+
export class StreamLocks {
|
|
14
|
+
/** Queue tail per key: settles only when the current holder finishes. */
|
|
15
|
+
private readonly tails = new Map<string, Promise<unknown>>()
|
|
16
|
+
|
|
17
|
+
/** Run `fn` while holding the lock for `key`. Holders run in FIFO launch
|
|
18
|
+
* order; a holder that throws releases the key and propagates its error to
|
|
19
|
+
* its own caller without rejecting (poisoning) later holders. */
|
|
20
|
+
run<T>(key: string, fn: () => T | Promise<T>): Promise<T> {
|
|
21
|
+
const tail = this.tails.get(key) ?? Promise.resolve()
|
|
22
|
+
// A failed holder must release the key, so the wait chain never rejects.
|
|
23
|
+
const turn = tail.then(
|
|
24
|
+
() => undefined,
|
|
25
|
+
() => undefined,
|
|
26
|
+
)
|
|
27
|
+
const run = turn.then(fn)
|
|
28
|
+
// Keep the chain alive regardless of this holder's fate, and drop the
|
|
29
|
+
// entry once this holder is the last one (the tail) so idle keys do not
|
|
30
|
+
// accumulate. A caller that already chained onto `tail` re-sets the entry
|
|
31
|
+
// with its own, which makes the equality check a no-op for it.
|
|
32
|
+
const released = run.then(
|
|
33
|
+
() => undefined,
|
|
34
|
+
() => undefined,
|
|
35
|
+
)
|
|
36
|
+
this.tails.set(key, released)
|
|
37
|
+
released.then(() => {
|
|
38
|
+
if (this.tails.get(key) === released) this.tails.delete(key)
|
|
39
|
+
})
|
|
40
|
+
return run
|
|
41
|
+
}
|
|
42
|
+
}
|