forked from mongrel-intelligence/cascade
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmanifest.ts
More file actions
326 lines (293 loc) · 14 KB
/
Copy pathmanifest.ts
File metadata and controls
326 lines (293 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
/**
* PMProviderManifest — the single declarative contract for a PM provider.
*
* Historically, adding a PM provider required edits in ~10 cross-cutting
* locations (router routes, adapter registry, trigger registry, credential
* roles, job-dispatch extractor, wizard state union, wizard hooks, wizard
* router, tRPC discovery endpoints). The Linear rollout surfaced this as
* four separate silent bugs in production. A manifest collapses every
* registration into one object per provider; a conformance harness
* (tests/unit/integrations/pm-conformance.test.ts) asserts the contract
* is fully implemented at CI time.
*
* A provider author writes ONE module that exports a `PMProviderManifest`
* and side-effectfully calls `registerPMProvider(manifest)` at load time.
* Nothing else in the codebase knows about that provider's existence.
*
* Frontend wizard definitions live in a parallel registry keyed by the
* same `id` — see web/src/components/projects/pm-providers/.
*/
import type { z } from 'zod';
import type { PMIntegration } from '../../pm/integration.js';
import type { ParsedWebhookEvent, RouterPlatformAdapter } from '../../router/platform-adapter.js';
import type { PlatformCommentClient } from '../../router/platformClients/types.js';
import type { CascadeJob } from '../../router/queue.js';
import type { TriggerHandler } from '../../types/index.js';
// ParsedWebhookEvent is referenced transitively by RouterPlatformAdapter and
// isSelfAuthoredHook; re-exported so callers that want to type their hooks
// don't need to know the internal path.
export type { ParsedWebhookEvent };
/**
* One credential the provider needs resolved at runtime. Mirrors the shape
* already in use by `registerCredentialRoles()` in `src/config/integrationRoles.ts`.
*/
export interface CredentialRoleSpec {
readonly role: string;
readonly label: string;
readonly envVarKey: string;
/** When `true`, the role is not required for `hasIntegration()` to return true. */
readonly optional?: boolean;
}
/**
* A verifier asserts the webhook payload came from the provider. Returns
* `true` when the request is authentic. Called with the raw body text (for
* HMAC computation) and the parsed headers. `secret` is `null` when the
* project has opted out of HMAC verification.
*/
export type WebhookVerifier = (
rawBody: string,
headers: Record<string, string | undefined>,
secret: string | null,
) => boolean;
/**
* Produces a platform client scoped to a project. The client posts
* acknowledgment comments during router-side webhook handling; it is
* distinct from the PMProvider used by agents (the adapter).
*/
export type PlatformClientFactory = (projectId: string) => PlatformCommentClient;
// ── Plan 009/1 additions: behavioral contract fields ────────────────────
//
// Three optional fields (plus a lifecycle opt-in) let a provider declare
// contracts the conformance harness then validates:
// - `configSchema` — a Zod schema for the persisted integration config.
// Eliminates the two-layer schema-drift class of bug that shipped
// `projectId` stripped twice (#1138 + #1142).
// - `discoveryCapabilities` — which discovery queries the adapter can
// serve. Consumed by the generic `pm.discover` tRPC endpoint.
// - `wizardSpec` — a declarative list of standard wizard steps the
// shared generator renders. Stops every provider from re-implementing
// the same credentials / container-pick / status-mapping UI.
// - `lifecycle` — opt-in flag + fixture for the full-lifecycle scenario
// the behavioral conformance harness runs against the adapter.
/** The discovery capabilities a provider may declare support for. */
export interface DiscoveryCapabilitiesMap {
readonly teams?: true;
readonly boards?: true;
readonly labels?: true;
readonly states?: true;
readonly projects?: true;
readonly customFields?: true;
readonly containers?: true;
/** Plan 010/2: restores "Verified as @username" wizard UX via generic dispatch. */
readonly currentUser?: true;
}
/** Every wizard step kind the generic generator knows how to render. */
export type StandardStepKind =
| 'credentials'
| 'container-pick'
| 'status-mapping'
| 'label-mapping'
| 'webhook-url-display'
| 'project-scope'
| 'custom-field-mapping';
export interface StandardStep {
readonly kind: StandardStepKind;
readonly id: string;
readonly config?: Readonly<Record<string, unknown>>;
}
export interface CustomStep {
readonly kind: 'custom';
readonly id: string;
/** Name of a provider-folder-owned component. The wizard shell resolves it through the providerWizardRegistry. */
readonly component: string;
readonly config?: Readonly<Record<string, unknown>>;
}
export interface WizardSpec {
readonly steps: ReadonlyArray<StandardStep | CustomStep>;
}
/** Lifecycle opt-in for the behavioral conformance harness. */
export interface LifecycleOptIn {
readonly enabled: true;
/**
* Opaque string key the test harness uses to look up the provider's
* lifecycle fixture in a test-only registry. Fixtures live under
* `tests/helpers/` and can't be imported from production code, so
* the manifest references them by key. When omitted, the harness
* falls back to the generic fake provider.
*/
readonly fixtureKey?: string;
}
export interface PMProviderManifest {
// ── Identity ────────────────────────────────────────────────────────
readonly id: string;
readonly label: string;
readonly category: 'pm';
// ── Credentials ─────────────────────────────────────────────────────
readonly credentialRoles: readonly CredentialRoleSpec[];
// ── Webhook ingestion ───────────────────────────────────────────────
/**
* Conventionally `/${id}/webhook`. Enforced by the conformance harness.
* Operators manually configure this URL in each provider's UI.
*/
readonly webhookRoute: string;
readonly verifyWebhookSignature: WebhookVerifier;
// ── Router-side dispatch ────────────────────────────────────────────
/**
* Includes `parseWebhook(raw)` which yields a ParsedWebhookEvent for
* router-side project resolution and trigger dispatch. Provider-domain
* parsing (PMWebhookEvent) lives on `pmIntegration.parseWebhookPayload`.
*/
readonly routerAdapter: RouterPlatformAdapter;
/**
* Extract the CASCADE projectId from a job payload produced by this
* provider's router adapter. Returns `null` when the job belongs to a
* different provider. Forgetting to implement this case was the root
* cause of Linear workers spawning without credentials (see #1118).
*/
readonly extractProjectIdFromJob: (jobData: CascadeJob) => Promise<string | null>;
// ── PM operations (agent-facing) ────────────────────────────────────
readonly pmIntegration: PMIntegration;
// ── Triggers ────────────────────────────────────────────────────────
readonly triggerHandlers: readonly TriggerHandler[];
// ── Router-side platform client (ack comments) ──────────────────────
readonly platformClientFactory: PlatformClientFactory;
// ── Optional provider-specific hooks ────────────────────────────────
/**
* Returns `true` when the event was authored by the bot itself.
* Optional — providers without self-authored webhook events can omit.
* When omitted, `false` is assumed.
*/
readonly isSelfAuthoredHook?: (
event: ParsedWebhookEvent,
payload: unknown,
projectId: string,
) => Promise<boolean>;
/**
* Create a single label on the provider (e.g. Trello board, Linear team).
* Manifests that support wizard-driven label creation implement this hook;
* others omit it and the generic `pm.discovery.createLabel` tRPC endpoint
* returns a 404 for that provider.
*/
readonly createLabel?: (opts: {
credentials: Record<string, string>;
containerId: string;
name: string;
color?: string;
}) => Promise<{ id: string; name: string; color: string }>;
/**
* Create a single custom field on the provider (e.g. Trello board,
* JIRA tenant). Plan 010/1 adds this hook as a sibling of `createLabel`.
* Manifests that support wizard-driven custom-field creation implement it;
* others omit it and `pm.discovery.createCustomField` returns
* NOT_IMPLEMENTED for that provider.
*
* Uses the same options-bag shape as `createLabel`. `credentials` is the
* shape declared by the manifest's `credentialRoles`; the hook is
* responsible for establishing its own credential scope (typically via
* the provider's `withXxxCredentials` AsyncLocalStorage helper).
*
* `containerId` is the provider-native scope (Trello board, JIRA project
* key, Linear team). JIRA custom fields are global — the hook accepts
* `containerId` for uniform shape but may ignore it internally.
*/
readonly createCustomField?: (opts: {
credentials: Record<string, string>;
containerId: string;
name: string;
}) => Promise<{ id: string; name: string; type: string }>;
// ── Plan 009/1 additions ─────────────────────────────────────────────
/**
* Zod schema for the provider's persisted integration config.
*
* When declared, the conformance harness asserts round-trip identity:
* a fixture config parsed → serialized → reparsed yields a deep-equal
* config. This eliminates the two-layer drift that shipped `projectId`
* stripped twice in Linear (#1138 + #1142).
*
* Plans 2/3/4 move each real provider's schema from `src/config/schema.ts`
* onto its manifest here. `configMapper` routes through the registry
* in plan 5.
*/
readonly configSchema?: z.ZodType<unknown>;
/**
* Optional sample config used by the conformance harness round-trip
* asserter. Must be parseable by `configSchema`. If absent, the harness
* falls back to the schema's default parse (may error — prefer to
* declare a fixture alongside the schema).
*/
readonly configFixture?: unknown;
/**
* The set of discovery capabilities this provider supports. Consumed
* by the generic `pm.discover` tRPC endpoint. An adapter that declares
* a capability here MUST implement the corresponding `discover(k, args)`
* method on the agent-facing PM adapter.
*/
readonly discoveryCapabilities?: DiscoveryCapabilitiesMap;
/**
* Declarative wizard step spec consumed by the shared wizard generator.
* Every step whose `kind` is a `StandardStepKind` is rendered by the
* generator from a shared component; `kind: 'custom'` steps are
* resolved through the provider-owned wizard folder.
*/
readonly wizardSpec?: WizardSpec;
/**
* Opt-in flag + fixture for the behavioral conformance harness's
* lifecycle scenario (create → list → move → checklist → comment →
* delete). Legacy providers keep `lifecycle` undefined — harness skips
* them. The fake PM provider and any migrated real provider set
* `lifecycle.enabled: true` and provide a fixture.
*/
readonly lifecycle?: LifecycleOptIn;
/**
* Optional factory for producing a PM adapter instance outside of a
* project context — used by the generic `pm.discover` tRPC endpoint
* during wizard setup, when the user hasn't saved a project yet so
* `pmIntegration.createProvider(project)` isn't applicable.
*
* Accepts raw credentials in the same shape the wizard collects; adapters
* may ignore the argument when discovery doesn't need credentials (e.g.
* the fake provider). Plans 2/3/4 wire each real provider's factory.
*/
readonly createDiscoveryProvider?: (opts?: {
credentials?: Record<string, string>;
}) => import('../../pm/types.js').PMProvider;
/**
* Promote fields from the persisted integration config into the credentials
* bag that `createDiscoveryProvider` consumes.
*
* Motivation: some providers (JIRA) require non-secret connection fields —
* like the cloud tenant URL — that belong on `project_integrations.config`,
* not in `project_credentials`. Without this hook, `pm.discovery.discover`
* resolving credentials by projectId produces a bag missing those fields,
* and the discovery adapter constructs a client with an empty host (see
* prod incident 2026-04-24: "Couldn't parse the host URL" in the JIRA
* wizard's Select Project step).
*
* Contract:
* - Invoked only on the projectId path of `resolvePMCredentials`. The
* explicit-credentials path (wizard first-time setup, with the user's
* raw form values) does not invoke it.
* - Values loaded from `project_credentials` take precedence on key
* collisions — hook-returned values fill gaps, they don't override.
* - Return `{}` (or undefined) when the config has nothing to promote.
*/
readonly configToCredentials?: (config: unknown) => Record<string, string>;
}
/**
* Asserts a manifest's declared `configSchema` accepts its `configFixture`.
*
* When both are declared, the harness calls this at CI time — a manifest
* author can also invoke it at module load for immediate feedback. The
* function is a no-op when `configSchema` is undefined (legacy providers
* that haven't migrated yet).
*/
export function validateManifestAgainstSchema(manifest: PMProviderManifest): void {
if (!manifest.configSchema) return;
if (manifest.configFixture === undefined) {
// No fixture to validate against — harness's round-trip step still
// runs with a schema-synthesized sample, so this is non-fatal.
return;
}
// Throws ZodError if the fixture doesn't parse.
manifest.configSchema.parse(manifest.configFixture);
}