@defrex/autobuild 0.12.0 → 0.14.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/README.md +1 -1
- package/SPEC.md +138 -16
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +155 -73
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +8 -1
- package/docs/remote-store-protocol.md +94 -2
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +23 -16
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +23 -79
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch-frontend.ts +24 -67
- package/packages/core/src/cli/dispatch-process.ts +14 -7
- package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
- package/packages/core/src/cli/dispatch.ts +304 -82
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +84 -42
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +23 -5
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/pr-attachments.ts +10 -13
- package/packages/core/src/cli/pr-summary-publication.ts +27 -60
- package/packages/core/src/cli/pr-summary.ts +43 -13
- package/packages/core/src/cli/publication-settlement.ts +10 -20
- package/packages/core/src/cli/repository-status.ts +38 -2
- package/packages/core/src/cli/self-update.ts +5 -1
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +166 -36
- package/packages/core/src/cli/wait.ts +91 -84
- package/packages/core/src/cli/watch.ts +60 -97
- package/packages/core/src/config/pipeline-source.ts +4 -3
- package/packages/core/src/config/schema.ts +35 -3
- package/packages/core/src/connection.ts +3 -3
- package/packages/core/src/events/catalog.ts +3 -2
- package/packages/core/src/events/payloads.ts +6 -0
- package/packages/core/src/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +38 -4
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
- package/packages/core/src/kernel/auto-merge.ts +19 -3
- package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
- package/packages/core/src/kernel/harvest.ts +25 -23
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index-value.ts +38 -0
- package/packages/core/src/kernel/read-facts.ts +637 -0
- package/packages/core/src/kernel/reducer-registry.ts +139 -3
- package/packages/core/src/kernel/repository-attention.ts +179 -0
- package/packages/core/src/kernel/service-facts.ts +135 -0
- package/packages/core/src/kernel/terminal-guard.ts +133 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +132 -22
- package/packages/core/src/plugin-sdk/index.ts +19 -0
- package/packages/core/src/plugins/runtimes.ts +5 -1
- package/packages/core/src/ports/runner/session-env.ts +2 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +48 -12
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
- package/packages/core/src/ports/tickets/remote.ts +60 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +1 -0
- package/packages/core/src/ports/workspace/create.ts +5 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
- package/packages/core/src/processes/build-child.ts +6 -6
- package/packages/core/src/processes/build-runner.ts +62 -70
- package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
- package/packages/core/src/processes/dispatcher.ts +223 -205
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +30 -4
- package/packages/core/src/processes/harvest-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +58 -74
- package/packages/core/src/processes/harvest.ts +72 -29
- package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
- package/packages/core/src/processes/orchestrator-tick.ts +2 -2
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1099
- package/packages/core/src/processes/tick-publication.ts +110 -0
- package/packages/core/src/store/adapter.ts +23 -0
- package/packages/core/src/store/build-scope.ts +17 -0
- package/packages/core/src/store/contract.ts +283 -1
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +180 -55
- package/packages/core/src/store/memory.ts +119 -29
- package/packages/core/src/store/phase-session.ts +17 -0
- package/packages/core/src/store/reduced-reads.ts +239 -0
- package/packages/core/src/store/reducer-value-shapes.json +14056 -0
- package/packages/core/src/store/reducer-value-validation.ts +96 -0
- package/packages/core/src/store/reducer-values.ts +145 -0
- package/packages/core/src/store/remote/client.ts +41 -0
- package/packages/core/src/store/remote/compatibility.ts +2 -0
- package/packages/core/src/store/remote/index.ts +19 -0
- package/packages/core/src/store/remote/protocol.ts +16 -0
- package/packages/core/src/store/remote/version.ts +3 -1
- package/packages/core/src/store/repo-state-events.ts +1 -1
- package/packages/core/src/store/repo-tick.ts +72 -0
- package/packages/core/src/store/session-handle.ts +19 -0
- package/packages/core/src/store/snapshots.ts +10 -0
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +40 -3
- package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
- package/packages/core/src/testing/index.ts +8 -0
- package/packages/core/src/testing/reduced-read-budget.ts +462 -0
- package/skills/guide/SKILL.md +97 -12
- package/skills/guide/references/operator-api.md +8 -1
- package/skills/guide/references/remote-store-protocol.md +94 -2
- package/skills/guide/references/remote-ticket-protocol.md +151 -9
- package/skills/guide/references/setup.md +33 -7
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +2 -2
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { ticketRefSchema } from '../../ontology'
|
|
3
3
|
|
|
4
|
+
/** Explicit opt-in: released clients through 0.12 use strict decoders without revisions.
|
|
5
|
+
* Source checkouts can share their release version, so version alone is insufficient. */
|
|
6
|
+
export const TICKET_REVISIONS_HEADER = 'x-autobuild-ticket-revisions'
|
|
7
|
+
export const TICKET_REVISIONS_CAPABILITY = '1'
|
|
8
|
+
|
|
4
9
|
/** Repository-owned policy sent with every hosted ticket operation. */
|
|
5
10
|
export const hostedTicketContextSchema = z.strictObject({
|
|
6
11
|
teamKey: z.string().min(1),
|
|
@@ -10,8 +15,19 @@ export const hostedTicketContextSchema = z.strictObject({
|
|
|
10
15
|
})
|
|
11
16
|
export type HostedTicketContext = z.infer<typeof hostedTicketContextSchema>
|
|
12
17
|
|
|
18
|
+
export const TICKET_COMMENTS_HEADER = 'x-autobuild-ticket-comments'
|
|
19
|
+
export const TICKET_COMMENTS_CAPABILITY = '1'
|
|
20
|
+
|
|
21
|
+
export const ticketCommentWireSchema = z.strictObject({
|
|
22
|
+
body: z.string(),
|
|
23
|
+
createdAt: z.string(),
|
|
24
|
+
author: z.string().optional(),
|
|
25
|
+
})
|
|
26
|
+
|
|
13
27
|
export const ticketWireSchema = z.strictObject({
|
|
28
|
+
revision: z.string().min(1),
|
|
14
29
|
ref: ticketRefSchema,
|
|
30
|
+
comments: z.array(ticketCommentWireSchema).optional(),
|
|
15
31
|
creationKey: z.string().optional(),
|
|
16
32
|
title: z.string(),
|
|
17
33
|
body: z.string(),
|
|
@@ -47,6 +63,7 @@ const draftSchema = z.strictObject({
|
|
|
47
63
|
blockedBy: z.array(z.string()).optional(),
|
|
48
64
|
})
|
|
49
65
|
const createOptionsSchema = z.strictObject({
|
|
66
|
+
requestedId: z.string().min(1).optional(),
|
|
50
67
|
state: z.string().optional(),
|
|
51
68
|
idempotencyKey: z.string().optional(),
|
|
52
69
|
})
|
|
@@ -62,8 +79,27 @@ function envelope<T extends z.ZodType>(input: T) {
|
|
|
62
79
|
return z.strictObject({ context: hostedTicketContextSchema, input })
|
|
63
80
|
}
|
|
64
81
|
|
|
82
|
+
const mutationSchema = z.discriminatedUnion('kind', [
|
|
83
|
+
z.strictObject({ kind: z.literal('update'), patch: updateSchema }),
|
|
84
|
+
z.strictObject({ kind: z.literal('transition'), state: z.string().min(1) }),
|
|
85
|
+
z.strictObject({
|
|
86
|
+
kind: z.literal('blockers'),
|
|
87
|
+
operation: z.enum(['block', 'unblock']),
|
|
88
|
+
ids: z.array(z.string().min(1)),
|
|
89
|
+
}),
|
|
90
|
+
])
|
|
91
|
+
|
|
65
92
|
export const hostedTicketRequestSchemas = {
|
|
93
|
+
'mutate-if-revision': envelope(
|
|
94
|
+
z.strictObject({
|
|
95
|
+
id: z.string().min(1),
|
|
96
|
+
expectedRevision: z.string().refine((v) => v.trim().length > 0),
|
|
97
|
+
mutation: mutationSchema,
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
66
100
|
'list-ready': envelope(criteriaSchema),
|
|
101
|
+
states: envelope(z.strictObject({})),
|
|
102
|
+
'add-state': envelope(z.strictObject({ name: z.string(), about: z.string().optional() })),
|
|
67
103
|
get: envelope(idSchema),
|
|
68
104
|
claim: envelope(idSchema),
|
|
69
105
|
comment: envelope(z.strictObject({ id: z.string().min(1), body: z.string() })),
|
|
@@ -81,3 +117,62 @@ export type HostedTicketOperation = keyof typeof hostedTicketRequestSchemas
|
|
|
81
117
|
export const HOSTED_TICKET_OPERATIONS = Object.keys(
|
|
82
118
|
hostedTicketRequestSchemas,
|
|
83
119
|
) as HostedTicketOperation[]
|
|
120
|
+
|
|
121
|
+
/** Project policy is separate from CRUD context and cannot be supplied by delegates. */
|
|
122
|
+
export const hostedTicketPolicySchema = z.strictObject({
|
|
123
|
+
version: z.literal(1),
|
|
124
|
+
tickets: z.strictObject({
|
|
125
|
+
source: z.literal('hosted'),
|
|
126
|
+
teamKey: z.string().min(1),
|
|
127
|
+
readyState: z
|
|
128
|
+
.string()
|
|
129
|
+
.min(1)
|
|
130
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
131
|
+
triageState: z
|
|
132
|
+
.string()
|
|
133
|
+
.min(1)
|
|
134
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
135
|
+
claimedState: z
|
|
136
|
+
.string()
|
|
137
|
+
.min(1)
|
|
138
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
139
|
+
doneState: z
|
|
140
|
+
.string()
|
|
141
|
+
.min(1)
|
|
142
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
143
|
+
createState: z
|
|
144
|
+
.string()
|
|
145
|
+
.min(1)
|
|
146
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
147
|
+
proposalState: z
|
|
148
|
+
.string()
|
|
149
|
+
.min(1)
|
|
150
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
151
|
+
readyLabels: z.array(z.string().min(1)).optional(),
|
|
152
|
+
}),
|
|
153
|
+
})
|
|
154
|
+
export type HostedTicketPolicy = z.infer<typeof hostedTicketPolicySchema>
|
|
155
|
+
|
|
156
|
+
export const ticketGrantSchema = z.strictObject({
|
|
157
|
+
id: z.string().min(1),
|
|
158
|
+
repo: z.string().min(1),
|
|
159
|
+
execution: z.string().min(1),
|
|
160
|
+
kind: z.enum(['runner', 'phase']),
|
|
161
|
+
parent: z.string().nullable(),
|
|
162
|
+
expiresAt: z.string().datetime(),
|
|
163
|
+
})
|
|
164
|
+
export const ticketGrantResponseSchema = z.strictObject({
|
|
165
|
+
token: z.string().startsWith('abt_'),
|
|
166
|
+
grant: ticketGrantSchema,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
export const ticketStateWireSchema = z.strictObject({
|
|
170
|
+
name: z.string(),
|
|
171
|
+
tickets: z.number().int().nonnegative(),
|
|
172
|
+
about: z.string().optional(),
|
|
173
|
+
})
|
|
174
|
+
export const ticketStatesWireSchema = z.array(ticketStateWireSchema)
|
|
175
|
+
export const ticketStateCreateWireSchema = z.strictObject({
|
|
176
|
+
state: ticketStateWireSchema,
|
|
177
|
+
created: z.boolean(),
|
|
178
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { hostedRequestedIds } from './requested-id'
|
|
2
|
+
import { TicketRevisionConflict } from './revision'
|
|
1
3
|
import type {
|
|
2
4
|
DependencyState,
|
|
3
5
|
Ticket,
|
|
@@ -6,16 +8,25 @@ import type {
|
|
|
6
8
|
TicketListing,
|
|
7
9
|
TicketSource,
|
|
8
10
|
TicketUpdate,
|
|
11
|
+
TicketMutation,
|
|
9
12
|
} from '../types'
|
|
10
|
-
import { AuthError } from '../../store/remote/client'
|
|
13
|
+
import { RemoteBuildStore, AuthError } from '../../store/remote/client'
|
|
11
14
|
import { errorBodySchema } from '../../store/remote/protocol'
|
|
12
15
|
import {
|
|
13
16
|
claimWireSchema,
|
|
17
|
+
TICKET_REVISIONS_HEADER,
|
|
18
|
+
TICKET_REVISIONS_CAPABILITY,
|
|
19
|
+
hostedTicketPolicySchema,
|
|
20
|
+
type HostedTicketPolicy,
|
|
21
|
+
TICKET_COMMENTS_HEADER,
|
|
22
|
+
TICKET_COMMENTS_CAPABILITY,
|
|
14
23
|
dependencyStatesWireSchema,
|
|
15
24
|
type HostedTicketContext,
|
|
16
25
|
successWireSchema,
|
|
17
26
|
ticketListingWireSchema,
|
|
18
27
|
ticketWireSchema,
|
|
28
|
+
ticketStatesWireSchema,
|
|
29
|
+
ticketStateCreateWireSchema,
|
|
19
30
|
} from './remote-protocol'
|
|
20
31
|
import {
|
|
21
32
|
AUTOBUILD_VERSION,
|
|
@@ -38,12 +49,27 @@ export interface HostedTicketSourceOptions extends HostedTicketContext {
|
|
|
38
49
|
/** Full TicketSource client for a hosted Autobuild service. */
|
|
39
50
|
export class HostedTicketSource implements TicketSource {
|
|
40
51
|
readonly name = 'hosted'
|
|
52
|
+
readonly requestedIds: ReturnType<typeof hostedRequestedIds>
|
|
53
|
+
readonly assets: import('../types').TicketAssetStore
|
|
41
54
|
private readonly base: string
|
|
42
55
|
private readonly token: string
|
|
43
56
|
private readonly fetchFn: HostedTicketFetch
|
|
44
57
|
private readonly context: HostedTicketContext
|
|
45
58
|
|
|
46
59
|
constructor(options: HostedTicketSourceOptions) {
|
|
60
|
+
this.requestedIds = hostedRequestedIds(options.teamKey)
|
|
61
|
+
const client = new RemoteBuildStore({
|
|
62
|
+
url: options.url,
|
|
63
|
+
token: options.token,
|
|
64
|
+
...(options.fetchFn ? { fetchFn: options.fetchFn as typeof fetch } : {}),
|
|
65
|
+
})
|
|
66
|
+
this.assets = {
|
|
67
|
+
ticketAssetLimits: client.ticketAssetLimits.bind(client),
|
|
68
|
+
putTicketAsset: client.putTicketAsset.bind(client),
|
|
69
|
+
getTicketAsset: client.getTicketAsset.bind(client),
|
|
70
|
+
listTicketAssets: client.listTicketAssets.bind(client),
|
|
71
|
+
removeTicketAsset: client.removeTicketAsset.bind(client),
|
|
72
|
+
}
|
|
47
73
|
this.base = options.url.replace(/\/+$/, '')
|
|
48
74
|
this.token = options.token
|
|
49
75
|
this.fetchFn = options.fetchFn ?? fetch
|
|
@@ -55,6 +81,10 @@ export class HostedTicketSource implements TicketSource {
|
|
|
55
81
|
}
|
|
56
82
|
}
|
|
57
83
|
|
|
84
|
+
async synchronizePolicy(policy: HostedTicketPolicy): Promise<void> {
|
|
85
|
+
await this.request('policy', hostedTicketPolicySchema.parse(policy), successWireSchema)
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
private project(ticket: Ticket): Ticket {
|
|
59
89
|
return { ...ticket, ref: { ...ticket.ref, source: this.name } }
|
|
60
90
|
}
|
|
@@ -68,7 +98,9 @@ export class HostedTicketSource implements TicketSource {
|
|
|
68
98
|
method: 'POST',
|
|
69
99
|
headers: {
|
|
70
100
|
'content-type': 'application/json',
|
|
101
|
+
[TICKET_COMMENTS_HEADER]: TICKET_COMMENTS_CAPABILITY,
|
|
71
102
|
authorization: `Bearer ${this.token}`,
|
|
103
|
+
[TICKET_REVISIONS_HEADER]: TICKET_REVISIONS_CAPABILITY,
|
|
72
104
|
[AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
|
|
73
105
|
[REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
|
|
74
106
|
},
|
|
@@ -77,8 +109,17 @@ export class HostedTicketSource implements TicketSource {
|
|
|
77
109
|
if (!response.ok) {
|
|
78
110
|
let message = `ticket service responded ${response.status}`
|
|
79
111
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
112
|
+
const body = (await response.json()) as Record<string, unknown>
|
|
113
|
+
if (
|
|
114
|
+
response.status === 409 &&
|
|
115
|
+
typeof body.currentRevision === 'string' &&
|
|
116
|
+
typeof body.expectedRevision === 'string' &&
|
|
117
|
+
typeof body.id === 'string'
|
|
118
|
+
)
|
|
119
|
+
throw new TicketRevisionConflict(body.id, body.expectedRevision, body.currentRevision)
|
|
120
|
+
message = errorBodySchema.parse(body).error
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof TicketRevisionConflict) throw error
|
|
82
123
|
// Keep the status-only safe fallback for non-protocol responses.
|
|
83
124
|
}
|
|
84
125
|
if (response.status === 401 || response.status === 403) throw new AuthError(message)
|
|
@@ -99,6 +140,14 @@ export class HostedTicketSource implements TicketSource {
|
|
|
99
140
|
return { ...listing, tickets: listing.tickets.map((ticket) => this.project(ticket)) }
|
|
100
141
|
}
|
|
101
142
|
|
|
143
|
+
async listStates() {
|
|
144
|
+
return this.request('states', {}, ticketStatesWireSchema)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async addState(name: string, opts: { about?: string } = {}) {
|
|
148
|
+
return this.request('add-state', { name, ...opts }, ticketStateCreateWireSchema)
|
|
149
|
+
}
|
|
150
|
+
|
|
102
151
|
async get(id: string): Promise<Ticket | null> {
|
|
103
152
|
const ticket = await this.request('get', { id }, ticketWireSchema.nullable())
|
|
104
153
|
return ticket === null ? null : this.project(ticket)
|
|
@@ -120,6 +169,14 @@ export class HostedTicketSource implements TicketSource {
|
|
|
120
169
|
return this.project(await this.request('create', { draft, options }, ticketWireSchema))
|
|
121
170
|
}
|
|
122
171
|
|
|
172
|
+
async mutateIfRevision(
|
|
173
|
+
id: string,
|
|
174
|
+
expectedRevision: string,
|
|
175
|
+
mutation: TicketMutation,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
await this.request('mutate-if-revision', { id, expectedRevision, mutation }, successWireSchema)
|
|
178
|
+
}
|
|
179
|
+
|
|
123
180
|
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
124
181
|
await this.request('update', { id, patch }, successWireSchema)
|
|
125
182
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Requested-id policies shared by adapter writes and migration preflight. */
|
|
2
|
+
export interface RequestedIdPolicy {
|
|
3
|
+
preserve(id: string): string | undefined
|
|
4
|
+
allocate(occupied: string[]): string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function validateFileTicketId(id: string): string {
|
|
8
|
+
if (!id || id === '.' || /[/\\\0]/.test(id) || id.includes('..'))
|
|
9
|
+
throw new Error(`file ticket source: invalid ticket id "${id}"`)
|
|
10
|
+
return id
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const fileRequestedIds: RequestedIdPolicy = {
|
|
14
|
+
preserve: validateFileTicketId,
|
|
15
|
+
allocate(occupied) {
|
|
16
|
+
let n = 1
|
|
17
|
+
while (occupied.includes(`file-${n}`)) n++
|
|
18
|
+
return `file-${n}`
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MAX_TICKET_NUMBER = 9223372036854775807n
|
|
23
|
+
export function hostedRequestedIds(teamKey: string): RequestedIdPolicy {
|
|
24
|
+
const numberOf = (id: string): bigint | undefined => {
|
|
25
|
+
if (!id.startsWith(`${teamKey}-`)) return undefined
|
|
26
|
+
const suffix = id.slice(teamKey.length + 1)
|
|
27
|
+
if (!/^[1-9][0-9]*$/.test(suffix)) return undefined
|
|
28
|
+
const n = BigInt(suffix)
|
|
29
|
+
if (n > MAX_TICKET_NUMBER) throw new Error(`ticket number exceeds PostgreSQL bigint: ${id}`)
|
|
30
|
+
return n
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
preserve(id) {
|
|
34
|
+
return numberOf(id) === undefined ? undefined : id
|
|
35
|
+
},
|
|
36
|
+
allocate(occupied) {
|
|
37
|
+
let max = 0n
|
|
38
|
+
for (const id of occupied) {
|
|
39
|
+
const n = numberOf(id)
|
|
40
|
+
if (n !== undefined && n > max) max = n
|
|
41
|
+
}
|
|
42
|
+
if (max === MAX_TICKET_NUMBER) throw new Error('ticket numbers exhausted')
|
|
43
|
+
return `${teamKey}-${max + 1n}`
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { Ticket, TicketMutation, TicketSource } from '../types'
|
|
3
|
+
|
|
4
|
+
/** Content versions intentionally return to the same value after an exact revert. */
|
|
5
|
+
export function ticketRevision(
|
|
6
|
+
ticket: Pick<Ticket, 'title' | 'body' | 'labels' | 'state' | 'blockedBy'> & {
|
|
7
|
+
comments?: Ticket['comments']
|
|
8
|
+
attachments?: unknown
|
|
9
|
+
},
|
|
10
|
+
): string {
|
|
11
|
+
return createHash('sha256')
|
|
12
|
+
.update(
|
|
13
|
+
JSON.stringify([
|
|
14
|
+
ticket.title,
|
|
15
|
+
ticket.body,
|
|
16
|
+
[...new Set(ticket.labels)].sort(),
|
|
17
|
+
ticket.state ?? null,
|
|
18
|
+
[...new Set(ticket.blockedBy ?? [])].sort(),
|
|
19
|
+
...(ticket.comments !== undefined || ticket.attachments !== undefined
|
|
20
|
+
? [ticket.comments ?? null, ticket.attachments ?? null]
|
|
21
|
+
: []),
|
|
22
|
+
]),
|
|
23
|
+
)
|
|
24
|
+
.digest('hex')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class TicketRevisionConflict extends Error {
|
|
28
|
+
constructor(
|
|
29
|
+
readonly id: string,
|
|
30
|
+
readonly expectedRevision: string,
|
|
31
|
+
readonly currentRevision: string,
|
|
32
|
+
) {
|
|
33
|
+
super(
|
|
34
|
+
`ticket "${id}" changed: expected revision ${expectedRevision}, current revision ${currentRevision}`,
|
|
35
|
+
)
|
|
36
|
+
this.name = 'TicketRevisionConflict'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TicketRevisionUnsupported extends Error {
|
|
41
|
+
constructor(source: string) {
|
|
42
|
+
super(`${source} ticket source does not support revision-checked writes`)
|
|
43
|
+
this.name = 'TicketRevisionUnsupported'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function checkTicketRevision(ticket: Ticket, expected: string): void {
|
|
48
|
+
if (!expected.trim()) throw new Error('expected revision must be nonblank')
|
|
49
|
+
if (ticket.revision !== expected)
|
|
50
|
+
throw new TicketRevisionConflict(ticket.ref.id, expected, ticket.revision)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function mutateTicketIfRevision(
|
|
54
|
+
source: TicketSource,
|
|
55
|
+
id: string,
|
|
56
|
+
expected: string,
|
|
57
|
+
mutation: TicketMutation,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
if (!expected.trim()) throw new Error('expected revision must be nonblank')
|
|
60
|
+
if (!source.mutateIfRevision) throw new TicketRevisionUnsupported(source.name)
|
|
61
|
+
await source.mutateIfRevision(id, expected, mutation)
|
|
62
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Shared state rules; configured names are deliberately not create-validated. */
|
|
2
|
+
export const STATE_NAME_RULE =
|
|
3
|
+
'a state name is one to forty characters of lowercase letters, digits, and hyphens, starting with a letter'
|
|
4
|
+
|
|
5
|
+
export function validateStateName(name: string, source = 'file ticket source'): void {
|
|
6
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(name)) {
|
|
7
|
+
throw new Error(`${source}: invalid state name "${name}" — ${STATE_NAME_RULE}`)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validateStatePurpose(
|
|
12
|
+
value: string | undefined,
|
|
13
|
+
source = 'file ticket source',
|
|
14
|
+
): string | undefined {
|
|
15
|
+
const about = value?.trim()
|
|
16
|
+
if (about !== undefined && (about === '' || /[\r\n]/.test(about))) {
|
|
17
|
+
throw new Error(`${source}: a state purpose must be one nonblank line`)
|
|
18
|
+
}
|
|
19
|
+
return about
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function orderStates(names: string[], lifecycle: readonly string[]): string[] {
|
|
23
|
+
const first = [...new Set(lifecycle)].filter((name) => names.includes(name))
|
|
24
|
+
return [...first, ...names.filter((name) => !first.includes(name)).sort()]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveState(
|
|
28
|
+
name: string,
|
|
29
|
+
existing: string[],
|
|
30
|
+
source = 'file ticket source',
|
|
31
|
+
): string {
|
|
32
|
+
if (existing.includes(name)) return name
|
|
33
|
+
const matches = existing.filter((s) => s.toLowerCase() === name.toLowerCase())
|
|
34
|
+
if (matches.length === 1) return matches[0] as string
|
|
35
|
+
const directories = source === 'file ticket source'
|
|
36
|
+
if (matches.length > 1) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${source}: ambiguous state "${name}" — it matches the ${directories ? 'directories' : 'states'} ${matches.join(', ')}`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
throw new Error(
|
|
42
|
+
`${source}: unknown state "${name}" — this tracker's states are ${directories ? 'the directories: ' : ''}${existing.join(', ')}`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Portable directory vocabulary, without guessing lifecycle roles. */
|
|
47
|
+
export function normalizeStateName(name: string): string {
|
|
48
|
+
if (/^[a-z][a-z0-9-]{0,39}$/.test(name)) return name
|
|
49
|
+
const candidate = name
|
|
50
|
+
.trim()
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
53
|
+
.replace(/-+/g, '-')
|
|
54
|
+
.replace(/^-|-$/g, '')
|
|
55
|
+
try {
|
|
56
|
+
validateStateName(candidate)
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error(`invalid state "${name}" (normalized: "${candidate}")`)
|
|
59
|
+
}
|
|
60
|
+
return candidate
|
|
61
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BuildStore } from '../store/types'
|
|
1
2
|
/**
|
|
2
3
|
* Ports (SPEC §3.2): interfaces to the world, each with swappable adapters.
|
|
3
4
|
* The kernel and processes depend only on these types; adapters live in
|
|
@@ -49,7 +50,19 @@ export interface SessionStreamSink extends SessionStreamEmitter {
|
|
|
49
50
|
// never consulted mid-build and never used as artifact storage. A file-based
|
|
50
51
|
// adapter with nowhere to put blobs must be fully workable.
|
|
51
52
|
|
|
53
|
+
/** A recorded comment, with its exact body and source timestamp. */
|
|
54
|
+
export interface TicketComment {
|
|
55
|
+
body: string
|
|
56
|
+
createdAt: string
|
|
57
|
+
author?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
export interface Ticket {
|
|
61
|
+
/** Opaque source version of title, body, labels, state and blockers. */
|
|
62
|
+
revision: string
|
|
63
|
+
/** Complete history oldest first when exposed by this read. Absent means
|
|
64
|
+
* unsupported; an empty array means no comments. Bodies are unchanged. */
|
|
65
|
+
comments?: TicketComment[]
|
|
53
66
|
ref: TicketRef
|
|
54
67
|
/** Stable source-native key used to correlate an externally created ticket
|
|
55
68
|
* with Autobuild's durable creation reservation. It is separate from the
|
|
@@ -106,6 +119,8 @@ export interface DependencyState {
|
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
export interface TicketCreateOptions {
|
|
122
|
+
/** Requested source id. Unsupported adapters must reject it. */
|
|
123
|
+
requestedId?: string
|
|
109
124
|
/** Explicit workflow state for this create (harvest always targets Triage). */
|
|
110
125
|
state?: string
|
|
111
126
|
/** Source-level adoption key for crash-safe external creation. File and fake
|
|
@@ -148,7 +163,29 @@ export class TicketSourceRateLimitError extends Error {
|
|
|
148
163
|
}
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
export type TicketAssetStore = {
|
|
167
|
+
/** Native ownership, including tombstones, prevents resurrection from legacy stores. */
|
|
168
|
+
hasTicketAssetHistory?(repo: string, id: string, kind: string, name: string): Promise<boolean>
|
|
169
|
+
} & Pick<
|
|
170
|
+
BuildStore,
|
|
171
|
+
| 'ticketAssetLimits'
|
|
172
|
+
| 'putTicketAsset'
|
|
173
|
+
| 'getTicketAsset'
|
|
174
|
+
| 'listTicketAssets'
|
|
175
|
+
| 'removeTicketAsset'
|
|
176
|
+
>
|
|
177
|
+
|
|
178
|
+
export type TicketMutation =
|
|
179
|
+
| { kind: 'update'; patch: TicketUpdate }
|
|
180
|
+
| { kind: 'transition'; state: string }
|
|
181
|
+
| { kind: 'blockers'; operation: 'block' | 'unblock'; ids: string[] }
|
|
182
|
+
|
|
151
183
|
export interface TicketSource {
|
|
184
|
+
/** Import support: the same pure id policy used by create, for mutation-free preflight. */
|
|
185
|
+
readonly requestedIds?: import('./tickets/requested-id').RequestedIdPolicy
|
|
186
|
+
readonly assets?: TicketAssetStore
|
|
187
|
+
/** Check and mutate at the source boundary; absent means checks are unsupported. */
|
|
188
|
+
mutateIfRevision?(id: string, expectedRevision: string, mutation: TicketMutation): Promise<void>
|
|
152
189
|
readonly name: string
|
|
153
190
|
/** Ready tickets matching the dispatch criteria (label/state — §3.3), plus
|
|
154
191
|
* actionable diagnostics for individual source records excluded as invalid.
|
|
@@ -193,9 +230,9 @@ export interface TicketSource {
|
|
|
193
230
|
*/
|
|
194
231
|
listStates?(): Promise<TicketStateInfo[]>
|
|
195
232
|
/**
|
|
196
|
-
* Optional state creation (`ab ticket state create`).
|
|
197
|
-
*
|
|
198
|
-
*
|
|
233
|
+
* Optional state creation (`ab ticket state create`). Duplicate policy is
|
|
234
|
+
* adapter-specific: file creation is idempotent and can replace purpose;
|
|
235
|
+
* hosted creation refuses existing names in any case. Rejects malformed names.
|
|
199
236
|
* Named `addState` because adapters already hold a `createState` option field.
|
|
200
237
|
*/
|
|
201
238
|
addState?(
|
|
@@ -116,6 +116,7 @@ export type ExecutionObservation =
|
|
|
116
116
|
* teardown of the environment and descendants it starts before completion. */
|
|
117
117
|
export interface ExecutionLaunchAuth {
|
|
118
118
|
token: string
|
|
119
|
+
tickets?: { url: string; token: string }
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
export interface BuildExecution {
|
|
@@ -132,7 +132,11 @@ export async function createWorkspaceProvider(
|
|
|
132
132
|
// receives through `opts` reach the plugin factory through the context.
|
|
133
133
|
return await factory({
|
|
134
134
|
config: config.config,
|
|
135
|
-
env: Object.fromEntries(
|
|
135
|
+
env: Object.fromEntries(
|
|
136
|
+
Object.entries(opts.env).filter(
|
|
137
|
+
([key]) => !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
136
140
|
repoRoot: resolve(opts.repoRoot),
|
|
137
141
|
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
138
142
|
...(opts.storeRef !== undefined && opts.storeToken !== undefined
|
|
@@ -641,7 +641,7 @@ function runtimeEnvironment(
|
|
|
641
641
|
): Record<string, string> {
|
|
642
642
|
return Object.fromEntries(
|
|
643
643
|
config.environmentVariables
|
|
644
|
-
.filter((name) =>
|
|
644
|
+
.filter((name) => !['AB_TOKEN', 'AB_TICKET_URL', 'AB_TICKET_TOKEN'].includes(name))
|
|
645
645
|
.map((name) => [name, requireVercelEnvironmentValue(env, name)]),
|
|
646
646
|
)
|
|
647
647
|
}
|
|
@@ -947,7 +947,7 @@ export async function validateVercelSandbox(
|
|
|
947
947
|
['AB_STORE', options.storeRef],
|
|
948
948
|
['AB_TOKEN', options.storeToken],
|
|
949
949
|
...config.environmentVariables
|
|
950
|
-
.filter((name) =>
|
|
950
|
+
.filter((name) => !['AB_TOKEN', 'AB_TICKET_URL', 'AB_TICKET_TOKEN'].includes(name))
|
|
951
951
|
.map((name) => [name, requireVercelEnvironmentValue(options.env, name)]),
|
|
952
952
|
]),
|
|
953
953
|
}),
|
|
@@ -1602,7 +1602,8 @@ export class VercelSandboxProvider implements WorkspaceProvider {
|
|
|
1602
1602
|
}),
|
|
1603
1603
|
}
|
|
1604
1604
|
for (const name of this.options.config.environmentVariables)
|
|
1605
|
-
if (
|
|
1605
|
+
if (!['AB_TOKEN', 'AB_TICKET_URL', 'AB_TICKET_TOKEN'].includes(name))
|
|
1606
|
+
env[name] = requireVercelEnvironmentValue(this.options.env, name)
|
|
1606
1607
|
const command = (await sandbox.runCommand({
|
|
1607
1608
|
cmd: 'sh',
|
|
1608
1609
|
args: [
|
|
@@ -1906,10 +1907,13 @@ export class VercelSandboxProvider implements WorkspaceProvider {
|
|
|
1906
1907
|
const env: Record<string, string> = {
|
|
1907
1908
|
AB_STORE: this.options.storeRef,
|
|
1908
1909
|
AB_TOKEN: scopedToken,
|
|
1910
|
+
...(auth?.tickets
|
|
1911
|
+
? { AB_TICKET_URL: auth.tickets.url, AB_TICKET_TOKEN: auth.tickets.token }
|
|
1912
|
+
: {}),
|
|
1909
1913
|
[HARVEST_RUNNER_OPTIONS_ENV]: JSON.stringify(envelope),
|
|
1910
1914
|
}
|
|
1911
1915
|
for (const envName of this.options.config.environmentVariables)
|
|
1912
|
-
if (
|
|
1916
|
+
if (!['AB_TOKEN', 'AB_TICKET_URL', 'AB_TICKET_TOKEN'].includes(envName))
|
|
1913
1917
|
env[envName] = requireVercelEnvironmentValue(this.options.env, envName)
|
|
1914
1918
|
const command = (await sandbox.runCommand({
|
|
1915
1919
|
cmd: 'sh',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readBuildFacts } from '../store/reduced-reads'
|
|
1
2
|
import { hostname } from 'node:os'
|
|
2
3
|
import { createForge } from '../ports/forge/create'
|
|
3
4
|
import { createProductionRuntimes } from '../ports/runner/production'
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
buildOwnedSectionsDiffer,
|
|
14
15
|
diagnosticArtifact,
|
|
15
16
|
parseEffectiveBuildConfig,
|
|
16
|
-
selectOpenWorkspace,
|
|
17
17
|
type BuildRunnerDiagnosticOutcome,
|
|
18
18
|
} from './build-execution-state'
|
|
19
19
|
import type { BuildExecutionStart } from '../ports/workspace/build-execution'
|
|
@@ -40,7 +40,7 @@ export async function runBuildChild(
|
|
|
40
40
|
try {
|
|
41
41
|
const record = await store.getBuild(input.slug)
|
|
42
42
|
if (record === null) throw new Error(`unknown build ${JSON.stringify(input.slug)}`)
|
|
43
|
-
const workspace =
|
|
43
|
+
const workspace = (await readBuildFacts(store, input.slug)).value.workspace
|
|
44
44
|
if (workspace === null) {
|
|
45
45
|
throw new Error(`build ${JSON.stringify(input.slug)} has no open durable workspace`)
|
|
46
46
|
}
|
|
@@ -55,20 +55,20 @@ export async function runBuildChild(
|
|
|
55
55
|
throw new Error(`build ${JSON.stringify(input.slug)} has no effective config artifact`)
|
|
56
56
|
}
|
|
57
57
|
let currentConfig = parseEffectiveBuildConfig(configArtifact)
|
|
58
|
-
const plugins = await loadPlugins(currentConfig.plugins, workspace.path, {
|
|
58
|
+
const plugins = await loadPlugins(currentConfig.plugins, workspace.path ?? workspace.ref, {
|
|
59
59
|
packageRoot: record.repo,
|
|
60
60
|
...(opts.guest === true ? { guest: true } : {}),
|
|
61
61
|
})
|
|
62
62
|
const { runtimes: builtins } = createProductionRuntimes()
|
|
63
63
|
const runtimes = await materializePluginRuntimes(builtins, plugins, {
|
|
64
|
-
repoRoot: workspace.path,
|
|
64
|
+
repoRoot: workspace.path ?? workspace.ref,
|
|
65
65
|
env,
|
|
66
66
|
})
|
|
67
67
|
const forge = await createForge({
|
|
68
68
|
name: currentConfig.forge,
|
|
69
69
|
registry: plugins,
|
|
70
70
|
env,
|
|
71
|
-
repoRoot: workspace.path,
|
|
71
|
+
repoRoot: workspace.path ?? workspace.ref,
|
|
72
72
|
})
|
|
73
73
|
const getConfig = async () => {
|
|
74
74
|
const latest = await store.getArtifact(input.slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
@@ -94,7 +94,7 @@ export async function runBuildChild(
|
|
|
94
94
|
config: currentConfig,
|
|
95
95
|
getConfig,
|
|
96
96
|
runtimes,
|
|
97
|
-
workspacePath: workspace.path,
|
|
97
|
+
workspacePath: workspace.path ?? workspace.ref,
|
|
98
98
|
branch: workspace.branch,
|
|
99
99
|
slug: input.slug,
|
|
100
100
|
exec: spawnExec,
|