@defrex/autobuild 0.12.0 → 0.13.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 +119 -15
- package/docs/architecture.md +59 -3
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +7 -0
- package/docs/remote-store-protocol.md +10 -0
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +3 -3
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +156 -3
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +20 -2
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/self-update.ts +5 -1
- 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 +164 -34
- 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/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/reducer-registry.ts +2 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +5 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +130 -20
- package/packages/core/src/plugin-sdk/index.ts +6 -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/dispatcher.ts +110 -32
- 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-runner.ts +24 -4
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -1
- 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 +1 -1
- package/skills/guide/SKILL.md +91 -11
- package/skills/guide/references/operator-api.md +7 -0
- package/skills/guide/references/remote-store-protocol.md +10 -0
- 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,3 +1,4 @@
|
|
|
1
|
+
import { executionNoProgressReducer } from '../processes/execution-no-progress'
|
|
1
2
|
import { summaryBoundaryReducer, summaryRequestsReducer } from './pr-summary'
|
|
2
3
|
import { dispatchFailureBudgetReducer } from '../processes/dispatch-failure-budget'
|
|
3
4
|
import { ticketSourceHealthReducer } from './ticket-source-health'
|
|
@@ -60,6 +61,7 @@ export interface RegisteredReducer {
|
|
|
60
61
|
/** Parameterized reducers are registered with representative arguments; the
|
|
61
62
|
* accumulator shape does not depend on them. */
|
|
62
63
|
export const REDUCERS: Record<string, RegisteredReducer> = {
|
|
64
|
+
executionNoProgress: { reducer: executionNoProgressReducer, covers: [] },
|
|
63
65
|
summaryBoundary: { reducer: summaryBoundaryReducer, covers: ['summaryBoundary'] },
|
|
64
66
|
summaryRequests: { reducer: summaryRequestsReducer, covers: ['pendingSummaryRequests'] },
|
|
65
67
|
ticketSourceHealth: { reducer: ticketSourceHealthReducer, covers: ['reduceTicketSourceHealth'] },
|
|
@@ -109,6 +109,7 @@ export const policyEscalationCauseSchema = z.enum([
|
|
|
109
109
|
'verify-failure-limit',
|
|
110
110
|
'reconcile-no-progress',
|
|
111
111
|
'dispatch-failure-limit',
|
|
112
|
+
'execution-no-progress',
|
|
112
113
|
'setup-failure-limit',
|
|
113
114
|
'infrastructure-failure-limit',
|
|
114
115
|
'phase-attempt-limit',
|
|
@@ -92,6 +92,18 @@ export const OPERATOR_TOOL_ANNOTATIONS = {
|
|
|
92
92
|
idempotentHint: false,
|
|
93
93
|
approval: 'default',
|
|
94
94
|
},
|
|
95
|
+
'tickets.states': {
|
|
96
|
+
readOnlyHint: true,
|
|
97
|
+
destructiveHint: false,
|
|
98
|
+
idempotentHint: true,
|
|
99
|
+
approval: 'never',
|
|
100
|
+
},
|
|
101
|
+
'tickets.state.create': {
|
|
102
|
+
readOnlyHint: false,
|
|
103
|
+
destructiveHint: false,
|
|
104
|
+
idempotentHint: false,
|
|
105
|
+
approval: 'default',
|
|
106
|
+
},
|
|
95
107
|
'tickets.list': {
|
|
96
108
|
readOnlyHint: true,
|
|
97
109
|
destructiveHint: false,
|
|
@@ -92,6 +92,9 @@ export type {
|
|
|
92
92
|
OperatorTicketQueue,
|
|
93
93
|
} from './tickets'
|
|
94
94
|
export {
|
|
95
|
+
prepareOperatorTicketAssets,
|
|
96
|
+
listOperatorTicketStates,
|
|
97
|
+
createOperatorTicketState,
|
|
95
98
|
getOperatorTicket,
|
|
96
99
|
listOperatorTickets,
|
|
97
100
|
mutateOperatorTicket,
|
|
@@ -132,3 +135,5 @@ export {
|
|
|
132
135
|
findEffectiveConfigRef,
|
|
133
136
|
decodeEffectiveConfig,
|
|
134
137
|
} from './query'
|
|
138
|
+
|
|
139
|
+
export { ticketStateCreateRequestSchema } from './protocol'
|
|
@@ -55,15 +55,27 @@ export const ticketCreateRequestSchema = z.strictObject({
|
|
|
55
55
|
state: z.string().min(1).optional(),
|
|
56
56
|
blockedBy: z.array(z.string().min(1)).optional(),
|
|
57
57
|
})
|
|
58
|
+
const expectedRevisionSchema = z
|
|
59
|
+
.string()
|
|
60
|
+
.refine((v) => v.trim().length > 0, 'expectedRevision must be nonblank')
|
|
61
|
+
.optional()
|
|
58
62
|
export const ticketUpdateRequestSchema = z
|
|
59
63
|
.strictObject({
|
|
64
|
+
expectedRevision: expectedRevisionSchema,
|
|
60
65
|
title: z.string().optional(),
|
|
61
66
|
body: z.string().optional(),
|
|
62
67
|
labels: z.array(z.string()).optional(),
|
|
63
68
|
})
|
|
64
|
-
.refine(
|
|
65
|
-
|
|
69
|
+
.refine(
|
|
70
|
+
(value) => value.title !== undefined || value.body !== undefined || value.labels !== undefined,
|
|
71
|
+
'update must name at least one field',
|
|
72
|
+
)
|
|
73
|
+
export const ticketMoveRequestSchema = z.strictObject({
|
|
74
|
+
expectedRevision: expectedRevisionSchema,
|
|
75
|
+
state: z.string().min(1),
|
|
76
|
+
})
|
|
66
77
|
export const ticketBlockerRequestSchema = z.strictObject({
|
|
78
|
+
expectedRevision: expectedRevisionSchema,
|
|
67
79
|
blockerIds: z.array(z.string().min(1)).min(1),
|
|
68
80
|
})
|
|
69
81
|
export type OperatorTicketCreateRequest = z.infer<typeof ticketCreateRequestSchema>
|
|
@@ -96,3 +108,8 @@ export const sessionApprovalRequestSchema = z.strictObject({
|
|
|
96
108
|
decision: z.enum(['approve', 'deny']),
|
|
97
109
|
})
|
|
98
110
|
export type OperatorSessionApprovalRequest = z.infer<typeof sessionApprovalRequestSchema>
|
|
111
|
+
|
|
112
|
+
export const ticketStateCreateRequestSchema = z.strictObject({
|
|
113
|
+
name: z.string(),
|
|
114
|
+
about: z.string().optional(),
|
|
115
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TicketRevisionConflict, TicketRevisionUnsupported } from '../ports/tickets/revision'
|
|
1
2
|
/**
|
|
2
3
|
* The agent tool registry — one typed table that is the single source of
|
|
3
4
|
* truth for the agent-facing operator surface (AUT-338).
|
|
@@ -58,6 +59,9 @@ import { answerAction, controlPrechecks, requireRouteBuild, RouteRefusalError }
|
|
|
58
59
|
import { SandboxOperationError } from '../ports/workspace/operator-sandbox'
|
|
59
60
|
import type { OperatorSandboxService } from './sandbox'
|
|
60
61
|
import {
|
|
62
|
+
prepareOperatorTicketAssets,
|
|
63
|
+
listOperatorTicketStates,
|
|
64
|
+
createOperatorTicketState,
|
|
61
65
|
getOperatorTicket,
|
|
62
66
|
listOperatorTickets,
|
|
63
67
|
mutateOperatorTicket,
|
|
@@ -598,7 +602,13 @@ export const TOOLS: readonly ToolEntry[] = [
|
|
|
598
602
|
user: attributed(ctx),
|
|
599
603
|
...(ctx.via !== undefined ? { via: ctx.via } : {}),
|
|
600
604
|
action,
|
|
601
|
-
...(readTicketBody !== undefined
|
|
605
|
+
...(readTicketBody !== undefined
|
|
606
|
+
? {
|
|
607
|
+
readTicketBody,
|
|
608
|
+
prepareTicketAssets: (ref, store, repo) =>
|
|
609
|
+
prepareOperatorTicketAssets({ ref, store, repo, backend: ctx.tickets }),
|
|
610
|
+
}
|
|
611
|
+
: {}),
|
|
602
612
|
})
|
|
603
613
|
},
|
|
604
614
|
),
|
|
@@ -691,6 +701,30 @@ export const TOOLS: readonly ToolEntry[] = [
|
|
|
691
701
|
})
|
|
692
702
|
},
|
|
693
703
|
),
|
|
704
|
+
defineTool(
|
|
705
|
+
'tickets.states',
|
|
706
|
+
'List all ticket states with ticket counts and optional purposes.',
|
|
707
|
+
z.strictObject({ repo: z.string().min(1) }),
|
|
708
|
+
'Array of {name,tickets,about?}.',
|
|
709
|
+
async (raw, ctx) => {
|
|
710
|
+
const input = raw as { repo: string }
|
|
711
|
+
return listOperatorTicketStates({
|
|
712
|
+
store: ctx.store,
|
|
713
|
+
repo: input.repo,
|
|
714
|
+
backend: ticketsOf(ctx),
|
|
715
|
+
})
|
|
716
|
+
},
|
|
717
|
+
),
|
|
718
|
+
defineTool(
|
|
719
|
+
'tickets.state.create',
|
|
720
|
+
'Create a custom ticket state with an optional one-line purpose. Hosted duplicates are refused.',
|
|
721
|
+
z.strictObject({ repo: z.string().min(1), name: z.string(), about: z.string().optional() }),
|
|
722
|
+
'{state: {name,tickets,about?},created}.',
|
|
723
|
+
async (raw, ctx) => {
|
|
724
|
+
const input = raw as { repo: string; name: string; about?: string }
|
|
725
|
+
return createOperatorTicketState({ store: ctx.store, backend: ticketsOf(ctx), ...input })
|
|
726
|
+
},
|
|
727
|
+
),
|
|
694
728
|
defineTool(
|
|
695
729
|
'tickets.list',
|
|
696
730
|
'List the repository’s ticket grooming queue from the configured ticket source.',
|
|
@@ -709,9 +743,9 @@ export const TOOLS: readonly ToolEntry[] = [
|
|
|
709
743
|
),
|
|
710
744
|
defineTool(
|
|
711
745
|
'tickets.get',
|
|
712
|
-
'Show one ticket with its blockers and any related build.',
|
|
746
|
+
'Show one ticket with its hosted comments, blockers and any related build.',
|
|
713
747
|
ticketDetailInput,
|
|
714
|
-
'{ticket, blockers, build}: the ticket, its dependency states, and the most relevant build for it (null when none).',
|
|
748
|
+
'{ticket, blockers, build}: the ticket (including hosted ticket.comments when available), its dependency states, and the most relevant build for it (null when none).',
|
|
715
749
|
async (raw, ctx) => {
|
|
716
750
|
const input = raw as z.infer<typeof ticketDetailInput>
|
|
717
751
|
return getOperatorTicket({
|
|
@@ -752,12 +786,12 @@ export const TOOLS: readonly ToolEntry[] = [
|
|
|
752
786
|
'{ticket, blockers, build}: the updated ticket.',
|
|
753
787
|
async (raw, ctx) => {
|
|
754
788
|
const input = raw as z.infer<typeof ticketsUpdateInput>
|
|
755
|
-
const { repo, id, ...patch } = input
|
|
789
|
+
const { repo, id, expectedRevision, ...patch } = input
|
|
756
790
|
return mutateOperatorTicket({
|
|
757
791
|
store: ctx.store,
|
|
758
792
|
repo,
|
|
759
793
|
backend: ticketsOf(ctx),
|
|
760
|
-
operation: { kind: 'update', id, patch },
|
|
794
|
+
operation: { kind: 'update', id, patch, expectedRevision },
|
|
761
795
|
})
|
|
762
796
|
},
|
|
763
797
|
),
|
|
@@ -772,7 +806,12 @@ export const TOOLS: readonly ToolEntry[] = [
|
|
|
772
806
|
store: ctx.store,
|
|
773
807
|
repo: input.repo,
|
|
774
808
|
backend: ticketsOf(ctx),
|
|
775
|
-
operation: {
|
|
809
|
+
operation: {
|
|
810
|
+
kind: 'block',
|
|
811
|
+
id: input.id,
|
|
812
|
+
blockerIds: input.blockerIds,
|
|
813
|
+
expectedRevision: input.expectedRevision,
|
|
814
|
+
},
|
|
776
815
|
})
|
|
777
816
|
},
|
|
778
817
|
),
|
|
@@ -787,7 +826,12 @@ export const TOOLS: readonly ToolEntry[] = [
|
|
|
787
826
|
store: ctx.store,
|
|
788
827
|
repo: input.repo,
|
|
789
828
|
backend: ticketsOf(ctx),
|
|
790
|
-
operation: {
|
|
829
|
+
operation: {
|
|
830
|
+
kind: 'unblock',
|
|
831
|
+
id: input.id,
|
|
832
|
+
blockerIds: input.blockerIds,
|
|
833
|
+
expectedRevision: input.expectedRevision,
|
|
834
|
+
},
|
|
791
835
|
})
|
|
792
836
|
},
|
|
793
837
|
),
|
|
@@ -802,7 +846,12 @@ export const TOOLS: readonly ToolEntry[] = [
|
|
|
802
846
|
store: ctx.store,
|
|
803
847
|
repo: input.repo,
|
|
804
848
|
backend: ticketsOf(ctx),
|
|
805
|
-
operation: {
|
|
849
|
+
operation: {
|
|
850
|
+
kind: 'move',
|
|
851
|
+
id: input.id,
|
|
852
|
+
state: input.state,
|
|
853
|
+
expectedRevision: input.expectedRevision,
|
|
854
|
+
},
|
|
806
855
|
})
|
|
807
856
|
},
|
|
808
857
|
),
|
|
@@ -964,6 +1013,10 @@ function mapDomainError(error: unknown): RegistryError {
|
|
|
964
1013
|
error: error.refusal.error,
|
|
965
1014
|
})
|
|
966
1015
|
}
|
|
1016
|
+
if (error instanceof TicketRevisionConflict)
|
|
1017
|
+
return new RegistryError('domain', error.message, { kind: 'conflict', error: error.message })
|
|
1018
|
+
if (error instanceof TicketRevisionUnsupported)
|
|
1019
|
+
return new RegistryError('domain', error.message, { kind: 'refusal', error: error.message })
|
|
967
1020
|
if (error instanceof TicketOperationError) {
|
|
968
1021
|
return new RegistryError('domain', error.message, {
|
|
969
1022
|
kind: error.code === 'not-found' ? 'not-found' : 'refusal',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ticketLifecycle } from '../ports/tickets/lifecycle'
|
|
1
2
|
/**
|
|
2
3
|
* A dispatcher ticket source as the registry's ticket backend (AUT-342):
|
|
3
4
|
* the tick's turn runner takes an `OperatorToolRegistry`, and
|
|
@@ -14,34 +15,29 @@ import type { Config } from '../config/schema'
|
|
|
14
15
|
import type { OperatorTicketBackend, OperatorTicketContext } from './tickets'
|
|
15
16
|
import type { TicketSource } from '../ports/types'
|
|
16
17
|
|
|
17
|
-
/** The dispatcher's resolved triage state (the same default
|
|
18
|
-
* `processes/dispatcher.ts` applies); inlined rather than imported so this
|
|
19
|
-
* operator-seam module does not pull the dispatcher's module graph. */
|
|
20
|
-
function defaultTriageState(config: Config): string {
|
|
21
|
-
return (
|
|
22
|
-
config.tickets.triageState ??
|
|
23
|
-
(config.tickets.source === 'linear' || config.tickets.source === 'hosted'
|
|
24
|
-
? 'Backlog'
|
|
25
|
-
: 'Triage')
|
|
26
|
-
)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
18
|
export function ticketBackendFromSource(options: {
|
|
30
19
|
source: TicketSource
|
|
31
20
|
config: Config
|
|
32
21
|
}): OperatorTicketBackend {
|
|
33
22
|
return {
|
|
34
23
|
sourceFor: (_context: OperatorTicketContext) => options.source,
|
|
35
|
-
statesFor: (_context: OperatorTicketContext,
|
|
24
|
+
statesFor: async (_context: OperatorTicketContext, source: TicketSource): Promise<string[]> => {
|
|
25
|
+
if (source.listStates) return (await source.listStates()).map((s) => s.name)
|
|
36
26
|
const tickets = options.config.tickets
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
tickets.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
27
|
+
const lifecycle = ticketLifecycle(tickets)
|
|
28
|
+
const states =
|
|
29
|
+
tickets.source === 'hosted'
|
|
30
|
+
? Object.values(lifecycle)
|
|
31
|
+
: [
|
|
32
|
+
lifecycle.triage,
|
|
33
|
+
tickets.readyState,
|
|
34
|
+
tickets.createState,
|
|
35
|
+
tickets.claimedState,
|
|
36
|
+
tickets.doneState,
|
|
37
|
+
lifecycle.proposal,
|
|
38
|
+
].filter(
|
|
39
|
+
(state): state is string => typeof state === 'string' && state.trim().length > 0,
|
|
40
|
+
)
|
|
45
41
|
return [...new Set(states)]
|
|
46
42
|
},
|
|
47
43
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { samplePinnedAssets, preparePinnedAssets } from '../store/ticket-assets'
|
|
2
|
+
import type { TicketRef } from '../ontology'
|
|
1
3
|
import type { Config } from '../config/schema'
|
|
2
4
|
import { reduceBuild } from '../kernel/reducer'
|
|
3
5
|
import {
|
|
6
|
+
TicketOperationError,
|
|
4
7
|
changeBlockers,
|
|
5
8
|
createTicket,
|
|
6
9
|
moveTicket,
|
|
@@ -9,7 +12,7 @@ import {
|
|
|
9
12
|
} from '../ports/tickets/operations'
|
|
10
13
|
import type { DependencyState, Ticket, TicketSource, TicketUpdate } from '../ports/types'
|
|
11
14
|
import type { BuildStore } from '../store/types'
|
|
12
|
-
import {
|
|
15
|
+
import { ticketLifecycle } from '../ports/tickets/lifecycle'
|
|
13
16
|
import { effectiveConfig } from './query'
|
|
14
17
|
|
|
15
18
|
export interface OperatorTicketContext {
|
|
@@ -21,6 +24,7 @@ export interface OperatorTicketContext {
|
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export interface OperatorTicketBackend {
|
|
27
|
+
contextFor?(repo: string): Promise<OperatorTicketContext>
|
|
24
28
|
sourceFor(context: OperatorTicketContext): TicketSource | Promise<TicketSource>
|
|
25
29
|
statesFor(context: OperatorTicketContext, source: TicketSource): string[] | Promise<string[]>
|
|
26
30
|
}
|
|
@@ -63,10 +67,11 @@ export async function openOperatorTickets(opts: {
|
|
|
63
67
|
store: BuildStore
|
|
64
68
|
repo: string
|
|
65
69
|
backend: OperatorTicketBackend
|
|
66
|
-
}): Promise<{
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
}): Promise<{ context: OperatorTicketContext; source: TicketSource }> {
|
|
71
|
+
const context = opts.backend.contextFor
|
|
72
|
+
? await opts.backend.contextFor(opts.repo)
|
|
73
|
+
: contextFor((await effectiveConfig(opts.store, opts.repo)).config, opts.repo)
|
|
74
|
+
return { context, source: await opts.backend.sourceFor(context) }
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
export async function listOperatorTickets(opts: {
|
|
@@ -76,9 +81,9 @@ export async function listOperatorTickets(opts: {
|
|
|
76
81
|
state?: string
|
|
77
82
|
labels?: string[]
|
|
78
83
|
}): Promise<OperatorTicketQueue> {
|
|
79
|
-
const {
|
|
80
|
-
const triageState =
|
|
81
|
-
const readyState =
|
|
84
|
+
const { context, source } = await openOperatorTickets(opts)
|
|
85
|
+
const triageState = ticketLifecycle(context.tickets).triage
|
|
86
|
+
const readyState = context.tickets.readyState
|
|
82
87
|
// The operator surface is a grooming queue. Unlike the CLI's ticket list,
|
|
83
88
|
// labels narrow the default state instead of replacing its lifecycle gate.
|
|
84
89
|
const criteria = {
|
|
@@ -103,10 +108,36 @@ async function matchingBuild(
|
|
|
103
108
|
store: BuildStore,
|
|
104
109
|
repo: string,
|
|
105
110
|
id: string,
|
|
111
|
+
source: TicketSource,
|
|
112
|
+
configuredSource: string,
|
|
106
113
|
): Promise<OperatorTicketBuild | null> {
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
// The service's database adapter implements the externally named hosted source.
|
|
115
|
+
const hosted =
|
|
116
|
+
source.name === 'hosted' || (configuredSource === 'hosted' && source.name === 'database')
|
|
117
|
+
const candidates = (await store.listBuilds()).filter(
|
|
118
|
+
(record) =>
|
|
119
|
+
record.repo === repo &&
|
|
120
|
+
(record.ticket?.source === source.name || (hosted && record.ticket?.source === 'hosted')),
|
|
109
121
|
)
|
|
122
|
+
const matches = candidates.filter((record) => record.ticket!.id === id)
|
|
123
|
+
// The service database store always projects current ids, even when there is
|
|
124
|
+
// no matching build. Only a hosted client over an unprojected legacy store
|
|
125
|
+
// needs fallback lookups; failure must not undo a mutation.
|
|
126
|
+
if (matches.length === 0 && source.name === 'hosted') {
|
|
127
|
+
const identities = new Map<string, string>()
|
|
128
|
+
try {
|
|
129
|
+
for (const record of candidates) {
|
|
130
|
+
const raw = record.ticket!.id
|
|
131
|
+
if (!identities.has(raw)) {
|
|
132
|
+
const ticket = await source.get(raw)
|
|
133
|
+
identities.set(raw, ticket?.ref.source === source.name ? ticket.ref.id : raw)
|
|
134
|
+
}
|
|
135
|
+
if (identities.get(raw) === id) matches.push(record)
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Build association is optional presentation; keep the successful ticket result.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
110
141
|
const projected = await Promise.all(
|
|
111
142
|
matches.map(async (record) => ({
|
|
112
143
|
record,
|
|
@@ -136,10 +167,20 @@ export async function getOperatorTicket(opts: {
|
|
|
136
167
|
backend: OperatorTicketBackend
|
|
137
168
|
id: string
|
|
138
169
|
}): Promise<OperatorTicketDetail> {
|
|
139
|
-
const { source } = await openOperatorTickets(opts)
|
|
170
|
+
const { source, context } = await openOperatorTickets(opts)
|
|
140
171
|
const ticket = await requireTicket(source, opts.id)
|
|
141
172
|
const blockers = await source.dependencyStates(ticket.blockedBy ?? [])
|
|
142
|
-
return {
|
|
173
|
+
return {
|
|
174
|
+
ticket,
|
|
175
|
+
blockers,
|
|
176
|
+
build: await matchingBuild(
|
|
177
|
+
opts.store,
|
|
178
|
+
opts.repo,
|
|
179
|
+
ticket.ref.id,
|
|
180
|
+
source,
|
|
181
|
+
context.tickets.source,
|
|
182
|
+
),
|
|
183
|
+
}
|
|
143
184
|
}
|
|
144
185
|
|
|
145
186
|
export async function mutateOperatorTicket(opts: {
|
|
@@ -155,21 +196,90 @@ export async function mutateOperatorTicket(opts: {
|
|
|
155
196
|
state?: string
|
|
156
197
|
blockedBy?: string[]
|
|
157
198
|
}
|
|
158
|
-
| { kind: 'update'; id: string; patch: TicketUpdate }
|
|
159
|
-
| { kind: 'move'; id: string; state: string }
|
|
160
|
-
| { kind: 'block' | 'unblock'; id: string; blockerIds: string[] }
|
|
199
|
+
| { kind: 'update'; id: string; patch: TicketUpdate; expectedRevision?: string }
|
|
200
|
+
| { kind: 'move'; id: string; state: string; expectedRevision?: string }
|
|
201
|
+
| { kind: 'block' | 'unblock'; id: string; blockerIds: string[]; expectedRevision?: string }
|
|
161
202
|
}): Promise<OperatorTicketDetail> {
|
|
162
|
-
const { source } = await openOperatorTickets(opts)
|
|
203
|
+
const { source, context } = await openOperatorTickets(opts)
|
|
163
204
|
const operation = opts.operation
|
|
164
205
|
if (operation.kind === 'update') await requireTicket(source, operation.id)
|
|
165
206
|
const ticket =
|
|
166
207
|
operation.kind === 'create'
|
|
167
208
|
? await createTicket(source, operation)
|
|
168
209
|
: operation.kind === 'update'
|
|
169
|
-
? (await updateTicket(
|
|
210
|
+
? (await updateTicket(
|
|
211
|
+
source,
|
|
212
|
+
operation.id,
|
|
213
|
+
operation.patch,
|
|
214
|
+
true,
|
|
215
|
+
operation.expectedRevision,
|
|
216
|
+
))!
|
|
170
217
|
: operation.kind === 'move'
|
|
171
|
-
? await moveTicket(source, operation.id, operation.state)
|
|
172
|
-
: await changeBlockers(
|
|
218
|
+
? await moveTicket(source, operation.id, operation.state, operation.expectedRevision)
|
|
219
|
+
: await changeBlockers(
|
|
220
|
+
source,
|
|
221
|
+
operation.id,
|
|
222
|
+
operation.blockerIds,
|
|
223
|
+
operation.kind,
|
|
224
|
+
operation.expectedRevision,
|
|
225
|
+
)
|
|
173
226
|
const blockers = await source.dependencyStates(ticket.blockedBy ?? [])
|
|
174
|
-
return {
|
|
227
|
+
return {
|
|
228
|
+
ticket,
|
|
229
|
+
blockers,
|
|
230
|
+
build: await matchingBuild(
|
|
231
|
+
opts.store,
|
|
232
|
+
opts.repo,
|
|
233
|
+
ticket.ref.id,
|
|
234
|
+
source,
|
|
235
|
+
context.tickets.source,
|
|
236
|
+
),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function prepareOperatorTicketAssets(opts: {
|
|
241
|
+
store: BuildStore
|
|
242
|
+
repo: string
|
|
243
|
+
backend?: OperatorTicketBackend
|
|
244
|
+
ref: TicketRef
|
|
245
|
+
}) {
|
|
246
|
+
if (!opts.backend) {
|
|
247
|
+
if (opts.ref.source !== 'hosted') return samplePinnedAssets(opts.store, opts.repo, opts.ref.id)
|
|
248
|
+
throw new Error('ticket operator backend is not configured')
|
|
249
|
+
}
|
|
250
|
+
const { context, source } = await openOperatorTickets({ ...opts, backend: opts.backend })
|
|
251
|
+
if (context.tickets.source !== opts.ref.source)
|
|
252
|
+
throw new Error('build ticket source differs from configured source')
|
|
253
|
+
return preparePinnedAssets(source, opts.store, opts.repo, opts.ref.id)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function listOperatorTicketStates(opts: {
|
|
257
|
+
store: BuildStore
|
|
258
|
+
repo: string
|
|
259
|
+
backend: OperatorTicketBackend
|
|
260
|
+
}) {
|
|
261
|
+
const { source } = await openOperatorTickets(opts)
|
|
262
|
+
if (!source.listStates)
|
|
263
|
+
throw new TicketOperationError('refusal', `state discovery is not supported by ${source.name}`)
|
|
264
|
+
return source.listStates()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function createOperatorTicketState(opts: {
|
|
268
|
+
store: BuildStore
|
|
269
|
+
repo: string
|
|
270
|
+
backend: OperatorTicketBackend
|
|
271
|
+
name: string
|
|
272
|
+
about?: string
|
|
273
|
+
}) {
|
|
274
|
+
const { source } = await openOperatorTickets(opts)
|
|
275
|
+
if (!source.addState)
|
|
276
|
+
throw new TicketOperationError('refusal', `state creation is not supported by ${source.name}`)
|
|
277
|
+
try {
|
|
278
|
+
return await source.addState(opts.name, opts.about === undefined ? {} : { about: opts.about })
|
|
279
|
+
} catch (error) {
|
|
280
|
+
throw new TicketOperationError(
|
|
281
|
+
'refusal',
|
|
282
|
+
error instanceof Error ? error.message : String(error),
|
|
283
|
+
)
|
|
284
|
+
}
|
|
175
285
|
}
|
|
@@ -152,6 +152,12 @@ export type {
|
|
|
152
152
|
|
|
153
153
|
export { FakeTicketSource } from '../ports/tickets/fake'
|
|
154
154
|
export { validateTicketUpdate } from '../ports/tickets/update'
|
|
155
|
+
export {
|
|
156
|
+
TicketRevisionConflict,
|
|
157
|
+
TicketRevisionUnsupported,
|
|
158
|
+
ticketRevision,
|
|
159
|
+
checkTicketRevision,
|
|
160
|
+
} from '../ports/tickets/revision'
|
|
155
161
|
export { FakeWorkspaceProvider } from '../ports/workspace/fake'
|
|
156
162
|
export { FakeForge } from '../ports/forge/fake'
|
|
157
163
|
// ScriptContext + defaultTurnResult are the scripted fake's own API: the
|
|
@@ -40,7 +40,11 @@ export async function materializePluginRuntimes(
|
|
|
40
40
|
const merged = Object.assign(Object.create(null) as RuntimeRegistry, builtins)
|
|
41
41
|
const config = Object.freeze({})
|
|
42
42
|
const env = Object.freeze(
|
|
43
|
-
Object.fromEntries(
|
|
43
|
+
Object.fromEntries(
|
|
44
|
+
Object.entries(opts.env).filter(
|
|
45
|
+
([key]) => !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
44
48
|
)
|
|
45
49
|
const context: PluginFactoryContext = {
|
|
46
50
|
config,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { updateTicket, moveTicket, changeBlockers } from './operations'
|
|
1
2
|
import { describe, expect, test } from 'bun:test'
|
|
2
3
|
import type {
|
|
3
4
|
DependencyState,
|
|
@@ -116,6 +117,64 @@ export function describeTicketSourceContract(
|
|
|
116
117
|
factory: TicketSourceContractFactory,
|
|
117
118
|
): void {
|
|
118
119
|
describe(`TicketSource contract: ${name}`, () => {
|
|
120
|
+
test('revisions cover mutable content and guarded writes fail closed', async () => {
|
|
121
|
+
await withTicketSource(factory, async (harness) => {
|
|
122
|
+
const source = harness.source
|
|
123
|
+
const created = await createTracked(
|
|
124
|
+
harness,
|
|
125
|
+
{ title: contractTicketTitle('revisions'), body: CONTRACT_TICKET_BODY },
|
|
126
|
+
{ state: harness.states.ready },
|
|
127
|
+
)
|
|
128
|
+
const id = created.ref.id
|
|
129
|
+
expect(created.revision.trim()).not.toBe('')
|
|
130
|
+
expect((await source.get(id))?.revision).toBe(created.revision)
|
|
131
|
+
expect(
|
|
132
|
+
(await source.listReady({ state: harness.states.ready })).tickets.find(
|
|
133
|
+
(t) => t.ref.id === id,
|
|
134
|
+
)?.revision,
|
|
135
|
+
).toBe(created.revision)
|
|
136
|
+
if (!source.mutateIfRevision) {
|
|
137
|
+
await expect(
|
|
138
|
+
updateTicket(source, id, { title: 'Never written' }, false, created.revision),
|
|
139
|
+
).rejects.toThrow('does not support')
|
|
140
|
+
expect((await source.get(id))?.revision).toBe(created.revision)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
let current = created
|
|
144
|
+
for (const patch of [
|
|
145
|
+
{ title: contractTicketTitle('renamed') },
|
|
146
|
+
{ body: 'Changed body' },
|
|
147
|
+
{ labels: [harness.editableLabel] },
|
|
148
|
+
]) {
|
|
149
|
+
await updateTicket(source, id, patch, false, current.revision)
|
|
150
|
+
const next = (await source.get(id))!
|
|
151
|
+
expect(next.revision).not.toBe(current.revision)
|
|
152
|
+
await expect(updateTicket(source, id, patch, false, current.revision)).rejects.toThrow(
|
|
153
|
+
'current revision',
|
|
154
|
+
)
|
|
155
|
+
expect(await source.get(id)).toEqual(next)
|
|
156
|
+
current = next
|
|
157
|
+
}
|
|
158
|
+
await moveTicket(source, id, harness.states.completed, current.revision)
|
|
159
|
+
const moved = (await source.get(id))!
|
|
160
|
+
expect(moved.revision).not.toBe(current.revision)
|
|
161
|
+
await expect(
|
|
162
|
+
moveTicket(source, id, harness.states.ready, current.revision),
|
|
163
|
+
).rejects.toThrow('current revision')
|
|
164
|
+
const blocker = await createTracked(harness, {
|
|
165
|
+
title: contractTicketTitle('blocker'),
|
|
166
|
+
body: CONTRACT_TICKET_BODY,
|
|
167
|
+
})
|
|
168
|
+
await changeBlockers(source, id, [blocker.ref.id], 'block', moved.revision)
|
|
169
|
+
const blocked = (await source.get(id))!
|
|
170
|
+
expect(blocked.revision).not.toBe(moved.revision)
|
|
171
|
+
await expect(
|
|
172
|
+
changeBlockers(source, id, [blocker.ref.id], 'unblock', moved.revision),
|
|
173
|
+
).rejects.toThrow('current revision')
|
|
174
|
+
await changeBlockers(source, id, [blocker.ref.id], 'unblock', blocked.revision)
|
|
175
|
+
expect((await source.get(id))?.revision).not.toBe(blocked.revision)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
119
178
|
test('completion projections agree across create, get, listing, and transitions', async () => {
|
|
120
179
|
await withTicketSource(factory, async (harness) => {
|
|
121
180
|
const created = await createTracked(
|
|
@@ -156,7 +215,13 @@ export function describeTicketSourceContract(
|
|
|
156
215
|
state: harness.states.ready,
|
|
157
216
|
labels: [],
|
|
158
217
|
})
|
|
159
|
-
|
|
218
|
+
const detail = await harness.source.get(created.ref.id)
|
|
219
|
+
expect(detail).not.toBeNull()
|
|
220
|
+
// Detail reads may expose structured history absent from write responses.
|
|
221
|
+
const { comments, ...record } = detail!
|
|
222
|
+
const { comments: createdComments, ...createdRecord } = created
|
|
223
|
+
expect(record).toEqual(createdRecord)
|
|
224
|
+
if (comments !== undefined) expect(comments).toEqual(createdComments ?? [])
|
|
160
225
|
})
|
|
161
226
|
})
|
|
162
227
|
|