@bakers-build/addon-remote-drupal 0.0.0-stage → 0.1.0-next.38

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-remote-drupal
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build conductor remote addon: the Drupal/JSON:API control-plane remote.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,121 @@
1
+ import type { ActiveRun, AddonDefinitionV2, AddonRegistration, BbRemote, ClaimedRun, ClaimOptions, ClaimWithheld, ConductorRegistration, ConductorStatus, FinalizableRun, FrozenTicket, InboxSnapshot, RemotePlugin, RunMetrics, RunWriteAttributes, Ticket, TicketMetric, UncleanTicket } from '@bakers-build/conductor/contract';
2
+ import type { MessageResult } from '@bakers-build/message';
3
+ export { AmbiguousProjectNameError } from '@bakers-build/conductor/contract';
4
+ export { createRunLogHttp, RunLogSizeError } from './run-log-http.js';
5
+ import type { BbProjectSelection } from '@bakers-build/conductor/project-context';
6
+ import { type AuthAdapter, type HttpClient, type JsonApiClient } from 'dropsh/plugin';
7
+ /**
8
+ * Fetches a run's current `log` transcript through the download route
9
+ * (BB-556 Finding 1), never through the JSON:API `log` attribute: since
10
+ * `BbRun::preSave()` always clears that attribute after moving its payload
11
+ * to a file, the attribute is empty on every reload and trusting it silently
12
+ * drops the real transcript.
13
+ *
14
+ * Addresses `bb_core.run_log_download_api`, not the workspace-prefixed
15
+ * `bb_core.run_log_download` (review finding C2): a route whose path starts
16
+ * with `/_workspace/{bb_workspace}` is rewritten by
17
+ * `BbWorkspaceRouteSubscriber` to require a real workspace SLUG in that
18
+ * segment, and the internal `_workspace` literal a client might try as a
19
+ * placeholder is deliberately made unaddressable by
20
+ * `BbWorkspacePathProcessor` — so a placeholder segment 404s before the
21
+ * route's own access check ever runs. The `_api` route sits outside that
22
+ * prefix and needs no slug at all; its gate is `bb_run.view`, identical to
23
+ * the workspace-prefixed one.
24
+ *
25
+ * CR1: the path is `/_bakers-api/run/{uuid}/log/{kind}`, not `/bakers/run/...` —
26
+ * `bakers` is the default workspace's slug (`BbWorkspace::DEFAULT_SLUG`), so a
27
+ * leading `bakers` segment collided with `BbWorkspacePathProcessor`, which
28
+ * rewrites ANY first segment matching a live workspace slug. `_bakers-api`
29
+ * starts with an underscore, which the slug pattern forbids, so it can never
30
+ * collide with a workspace.
31
+ */
32
+ export type RunLogFetcher = (runUuid: string) => Promise<string>;
33
+ export declare function createRunLogFetcher(http: HttpClient, auth: AuthAdapter, baseUrl: string): RunLogFetcher;
34
+ export declare class DrupalBbRemote implements BbRemote {
35
+ private readonly api;
36
+ private readonly selection?;
37
+ private readonly resolvedProjectId?;
38
+ private readonly fetchRunLog;
39
+ constructor(api: JsonApiClient, selection?: BbProjectSelection | undefined, resolvedProjectId?: string | undefined, fetchRunLog?: RunLogFetcher);
40
+ fetchActiveRuns(id: string): Promise<ActiveRun[]>;
41
+ activeRunCount(id: string): Promise<number>;
42
+ machineCapacity(hostKey: string): Promise<{
43
+ maxParallel: number;
44
+ currentLoad: number;
45
+ } | null>;
46
+ claimNext(c: ClaimOptions): Promise<ClaimedRun | ClaimWithheld | null>;
47
+ getTicket(uuid: string): Promise<Ticket>;
48
+ getReachableWorkspaces(): Promise<string[]>;
49
+ getRunWorktree(uuid: string): Promise<string>;
50
+ getRunTicketIdentifier(uuid: string): Promise<string>;
51
+ getRunTicketBranchName(uuid: string): Promise<string>;
52
+ /**
53
+ * POSTs a conductor registration to the heartbeat resource.
54
+ *
55
+ * Registration omits `current_load` so a startup ping cannot overwrite a
56
+ * live process's reported load. Heartbeat supplies load and lease.
57
+ */
58
+ private postHeartbeatRegistration;
59
+ registerConductor(reg: ConductorRegistration): Promise<string>;
60
+ heartbeat(reg: ConductorRegistration, load: number, lease?: number): Promise<string>;
61
+ registerAddons(conductorId: string, definitions: Array<AddonDefinitionV2>): Promise<AddonRegistration>;
62
+ fetchInbox(conductorId: string): Promise<InboxSnapshot>;
63
+ writeInboxResult(messageUuid: string, status: 'running' | 'done' | 'failed', result?: MessageResult): Promise<void>;
64
+ getConductorStatus(id: string): Promise<string | null>;
65
+ setConductorStatus(id: string, status: 'offline'): Promise<void>;
66
+ listConductors(owner?: 'me'): Promise<ConductorStatus[]>;
67
+ markRunning(uuid: string, attrs?: RunWriteAttributes): Promise<void>;
68
+ markFailed(uuid: string, errorLog: string): Promise<void>;
69
+ fetchFinalizableRuns(id: string): Promise<FinalizableRun[]>;
70
+ private finalizableRunsInState;
71
+ finalizeRun(uuid: string, log: string, metrics?: RunMetrics): Promise<void>;
72
+ /**
73
+ * Closes the run without the transcript after a recognized size rejection.
74
+ *
75
+ * A lost fallback response is reconciled by reading `closed` and must not
76
+ * send a third write.
77
+ */
78
+ private closeOmittingOversizeLog;
79
+ private runIsClosed;
80
+ fetchUncleanedTickets(id: string): Promise<UncleanTicket[]>;
81
+ fetchFrozenTickets(id: string): Promise<FrozenTicket[]>;
82
+ replaceTicketMetrics(uuid: string, namespace: string, rows: TicketMetric[]): Promise<void>;
83
+ markTicketCleanedUp(uuid: string): Promise<void>;
84
+ /**
85
+ * UUID + absolute worktree path of the ticket's latest run (highest run id
86
+ * with a non-empty worktree_path), or empty strings when none — the cwd the
87
+ * cleanup command runs in, plus the record its teardown is noted on
88
+ * (BB-293). The conductor persists worktree_path on markRunning, so a done
89
+ * ticket's run carries the path even after the run closed.
90
+ *
91
+ * The uuid rides along on the query that already resolves the path, so the
92
+ * reaper learns which run to write to at no extra round-trip.
93
+ */
94
+ private latestRun;
95
+ appendRunNote(runUuid: string, note: string): Promise<void>;
96
+ resolveTicketByIdentifier(project: string, identifier: string): Promise<{
97
+ uuid: string;
98
+ title: string;
99
+ } | null>;
100
+ private readonly projectUuidCache;
101
+ /**
102
+ * The project *name* behind a project uuid — the inverse of projectUuid.
103
+ *
104
+ * A conductor row references its project; the CLI and the cockpit want to
105
+ * show the name. Best-effort: an unreadable project must not fail a listing,
106
+ * so it degrades to '' and the row still renders.
107
+ */
108
+ private projectName;
109
+ /**
110
+ * Host concurrent-run budget and `host_key` for a machine uuid (BB-353,
111
+ * BB-450). Both live on `bb_machine`, never on `bb_conductor`.
112
+ *
113
+ * Best-effort: an unreadable or missing machine degrades to
114
+ * `{maxParallel: 0, hostKey: ''}` so the row still lists; capacity and
115
+ * identity are never invented from a conductor field.
116
+ */
117
+ private machineDetails;
118
+ private projectUuid;
119
+ }
120
+ export declare function drupalRemote(): RemotePlugin;
121
+ export default drupalRemote;
@@ -0,0 +1,812 @@
1
+ import { createBbConnectionContext, toUnixSeconds } from '@bakers-build/core';
2
+ import { AmbiguousProjectNameError, resolveBbProjectContext, } from '@bakers-build/conductor/contract';
3
+ export { AmbiguousProjectNameError } from '@bakers-build/conductor/contract';
4
+ export { createRunLogHttp, RunLogSizeError } from './run-log-http.js';
5
+ import { HttpError, } from 'dropsh/plugin';
6
+ import { createRunLogHttp, RunLogSizeError } from './run-log-http.js';
7
+ export function createRunLogFetcher(http, auth, baseUrl) {
8
+ return async (runUuid) => {
9
+ const base = baseUrl.replace(/\/+$/, '');
10
+ const request = await auth.apply({
11
+ method: 'GET',
12
+ url: `${base}/_bakers-api/run/${runUuid}/log/log`,
13
+ headers: { 'Accept-Encoding': 'gzip' },
14
+ });
15
+ // CR2: a plain 404 means the route didn't resolve at all (bad path, wrong
16
+ // workspace routing, run not found) — that must fail loudly, never be
17
+ // read as "no transcript". Only a 204 ("no log file referenced") is the
18
+ // distinct, intentional "no transcript" signal from RunLogController, so
19
+ // only that maps to ''. Treating 404 as '' is exactly what let a routing
20
+ // bug (CR1) make appendRunNote() silently destroy real transcripts.
21
+ const res = await http.send(request);
22
+ if (res.status === 204) {
23
+ return '';
24
+ }
25
+ return res.body;
26
+ };
27
+ }
28
+ const ACTIVE = ['claimed', 'running'];
29
+ /**
30
+ * The process-identity attributes a registration carries, if any (BB-232).
31
+ *
32
+ * Spread rather than assigned so an absent value sends no key at all: the
33
+ * server treats a missing key as "leave the stored value alone", which is what
34
+ * lets `poll` and `reap` heartbeat past a running conductor without wiping the
35
+ * pid it needs to be stoppable.
36
+ */
37
+ function processIdentity(reg) {
38
+ return {
39
+ ...(reg.last_pid === undefined ? {} : { last_pid: reg.last_pid }),
40
+ ...(reg.conductor_name === undefined
41
+ ? {}
42
+ : { conductor_name: reg.conductor_name }),
43
+ };
44
+ }
45
+ /**
46
+ * The default `fetchRunLog`, used only where a construction site passes none.
47
+ *
48
+ * Review finding C3: the previous default (`async () => ''`) made "cannot
49
+ * fetch the transcript" indistinguishable from "there is no transcript",
50
+ * which is exactly the condition that let `appendRunNote()` silently replace
51
+ * a transcript with the reaper note. A construction site that genuinely never
52
+ * calls `appendRunNote()` never hits this; one that does gets a clear error
53
+ * instead of quiet data loss.
54
+ */
55
+ async function throwingRunLogFetcher() {
56
+ throw new Error('DrupalBbRemote was constructed without a fetchRunLog implementation; appendRunNote() cannot read the existing transcript.');
57
+ }
58
+ export class DrupalBbRemote {
59
+ api;
60
+ selection;
61
+ resolvedProjectId;
62
+ fetchRunLog;
63
+ constructor(api, selection, resolvedProjectId, fetchRunLog = throwingRunLogFetcher) {
64
+ this.api = api;
65
+ this.selection = selection;
66
+ this.resolvedProjectId = resolvedProjectId;
67
+ this.fetchRunLog = fetchRunLog;
68
+ }
69
+ async fetchActiveRuns(id) {
70
+ // The conductor identity `id` is the registration key (conductor_id).
71
+ // bb_run links to its owning conductor via the `conductor_id` entity-ref;
72
+ // filter on the related conductor's registration string field.
73
+ const rows = await this.api
74
+ .collection('bb_run')
75
+ .where('conductor_id.conductor_id', '=', id)
76
+ .whereIn('state', ACTIVE)
77
+ .fields(['state', 'state_at_start', 'worktree_path'])
78
+ .page(100)
79
+ .list();
80
+ return Promise.all(rows.map(async (r) => ({
81
+ runUuid: r.id,
82
+ ticketUuid: r.rel('ticket_id') ?? '',
83
+ ticketIdentifier: await this.getRunTicketIdentifier(r.id),
84
+ branchName: await this.getRunTicketBranchName(r.id),
85
+ state: r.attr('state') ?? '',
86
+ stateAtStart: r.attr('state_at_start') ?? '',
87
+ worktreePath: r.attr('worktree_path') ?? '',
88
+ })));
89
+ }
90
+ async activeRunCount(id) {
91
+ // Filter on the related conductor's registration string field.
92
+ return this.api
93
+ .collection('bb_run')
94
+ .where('conductor_id.conductor_id', '=', id)
95
+ .whereIn('state', ACTIVE)
96
+ .fields(['drupal_internal__id'])
97
+ .page(100)
98
+ .count();
99
+ }
100
+ async machineCapacity(hostKey) {
101
+ // Shared host budget (BB-353 D5): read bb_machine after heartbeat so
102
+ // current_load is peers + this process. Filter on host_key (MachineContext
103
+ // stem), not the composed conductor_id.
104
+ const row = await this.api
105
+ .collection('bb_machine')
106
+ .where('host_key', '=', hostKey)
107
+ .fields(['max_parallel', 'current_load'])
108
+ .first();
109
+ if (!row)
110
+ return null;
111
+ return {
112
+ maxParallel: row.attr('max_parallel') ?? 1,
113
+ currentLoad: row.attr('current_load') ?? 0,
114
+ };
115
+ }
116
+ async claimNext(c) {
117
+ const res = (await this.api.post('bakers/claim-next', {
118
+ data: {
119
+ attributes: {
120
+ lease_seconds: c.leaseSeconds,
121
+ // Server (`ConductorResolverTrait`) narrows to the caller's conductor
122
+ // by conductor_id (legacy machine_id still accepted).
123
+ conductor_id: c.conductorId,
124
+ machine_id: c.conductorId,
125
+ },
126
+ },
127
+ }));
128
+ if (!res?.data) {
129
+ const claim = res?.meta?.claim;
130
+ if (claim?.reason === 'machine_capacity') {
131
+ if (typeof claim.occupancy !== 'number' ||
132
+ typeof claim.capacity !== 'number' ||
133
+ !Number.isInteger(claim.occupancy) ||
134
+ !Number.isInteger(claim.capacity) ||
135
+ claim.capacity < 1 ||
136
+ claim.occupancy < claim.capacity ||
137
+ claim.retry_at !== null) {
138
+ throw new Error('claimNext: invalid machine capacity outcome');
139
+ }
140
+ return {
141
+ reason: 'machine_capacity',
142
+ occupancy: claim.occupancy,
143
+ capacity: claim.capacity,
144
+ retryAt: null,
145
+ };
146
+ }
147
+ return null;
148
+ }
149
+ const d = res.data;
150
+ const rawId = d.attributes?.drupal_internal__id;
151
+ if (rawId === undefined || rawId === null) {
152
+ throw new Error(`claimNext: missing drupal_internal__id on run ${d.id}`);
153
+ }
154
+ return {
155
+ runUuid: d.id,
156
+ runId: Number(rawId),
157
+ ticketUuid: d.relationships?.ticket_id?.data?.id ?? '',
158
+ stateAtStart: String(d.attributes?.state_at_start ?? ''),
159
+ // handler_id was dropped; the handler is the work the run does, i.e. the
160
+ // ticket state the run started in.
161
+ handler: String(d.attributes?.state_at_start ?? ''),
162
+ };
163
+ }
164
+ async getTicket(uuid) {
165
+ // The agent self-reads the ticket + its comments at run start (BB-112),
166
+ // so getTicket needs no ?include=comments — but it DOES sideload labels +
167
+ // environments (BB-144) so a config-side priority(ticket) can read them
168
+ // without fetching (AC-5). BB-495: the dotted `project_id.group_id.
169
+ // workspace_id` include resolves the ticket's project AND its workspace
170
+ // in the SAME request — a `bb_project` belongs to a `bb_group` (the
171
+ // OG group), and a `bb_group` belongs to a `bb_workspace`.
172
+ const doc = (await this.api.get(`bb_ticket/bb_ticket/${uuid}?include=labels,environments,project_id.group_id.workspace_id`));
173
+ const attrs = doc.data?.attributes ?? {};
174
+ const issueUrl = typeof attrs.origin === 'string' ? attrs.origin : undefined;
175
+ const included = Array.isArray(doc.included) ? doc.included : [];
176
+ const byId = new Map(included.map((r) => [`${r.type}:${r.id}`, r]));
177
+ const refs = (name) => {
178
+ const data = doc.data?.relationships?.[name]?.data;
179
+ return Array.isArray(data) ? data : [];
180
+ };
181
+ const ref = (source, name) => {
182
+ const rel = source?.relationships?.[name]?.data;
183
+ return rel && !Array.isArray(rel) ? rel : undefined;
184
+ };
185
+ const labels = refs('labels')
186
+ .map((r) => byId.get(`${r.type}:${r.id}`)?.attributes?.name)
187
+ .filter((n) => typeof n === 'string');
188
+ const environments = refs('environments')
189
+ .map((r) => {
190
+ const a = byId.get(`${r.type}:${r.id}`)?.attributes ?? {};
191
+ return {
192
+ name: typeof a.name === 'string' ? a.name : '',
193
+ tier: typeof a.tier === 'string' ? a.tier : '',
194
+ };
195
+ })
196
+ .filter((e) => e.name !== '');
197
+ const projectRef = ref(doc.data, 'project_id');
198
+ const project = projectRef
199
+ ? byId.get(`${projectRef.type}:${projectRef.id}`)
200
+ : undefined;
201
+ const projectName = typeof project?.attributes?.name === 'string'
202
+ ? project.attributes.name
203
+ : undefined;
204
+ const groupRef = ref(project, 'group_id');
205
+ const group = groupRef
206
+ ? byId.get(`${groupRef.type}:${groupRef.id}`)
207
+ : undefined;
208
+ const workspaceRef = ref(group, 'workspace_id');
209
+ const workspace = workspaceRef
210
+ ? byId.get(`${workspaceRef.type}:${workspaceRef.id}`)
211
+ : undefined;
212
+ const workspaceName = typeof workspace?.attributes?.name === 'string'
213
+ ? workspace.attributes.name
214
+ : undefined;
215
+ return {
216
+ uuid: doc.data?.id ?? uuid,
217
+ identifier: typeof attrs.identifier === 'string' ? attrs.identifier : uuid,
218
+ title: typeof attrs.title === 'string' ? attrs.title : '',
219
+ state: typeof attrs.state === 'string' ? attrs.state : '',
220
+ branchName: typeof attrs.branch_name === 'string' ? attrs.branch_name : '',
221
+ ...(typeof attrs.base_branch === 'string' && attrs.base_branch !== ''
222
+ ? { baseBranch: attrs.base_branch }
223
+ : {}),
224
+ ...(typeof attrs.effective_env_vars === 'string' &&
225
+ attrs.effective_env_vars !== ''
226
+ ? { effectiveEnvVars: attrs.effective_env_vars }
227
+ : {}),
228
+ ...(issueUrl ? { issueUrl } : {}),
229
+ ...(projectRef ? { projectId: projectRef.id } : {}),
230
+ ...(projectName ? { projectName } : {}),
231
+ ...(workspaceRef ? { workspaceId: workspaceRef.id } : {}),
232
+ ...(workspaceName ? { workspaceName } : {}),
233
+ labels,
234
+ environments,
235
+ };
236
+ }
237
+ async getReachableWorkspaces() {
238
+ // BB-495: every workspace uuid the dispatching identity can reach, so
239
+ // the agent knows the full radius it may name on a call, not only the
240
+ // point (workspaceId) it starts on. The server's own OG read scope already
241
+ // narrows this collection to reachable rows; 200 covers any realistic
242
+ // membership count in one page (this is a dispatch-time convenience, not
243
+ // an authoritative enumeration — `bakers info`'s fallback paginates fully).
244
+ const rows = await this.api.collection('bb_workspace').page(200).list();
245
+ return rows.map((r) => r.id);
246
+ }
247
+ async getRunWorktree(uuid) {
248
+ const r = await this.api.resource('bb_run', uuid);
249
+ return r.attr('worktree_path') ?? '';
250
+ }
251
+ async getRunTicketIdentifier(uuid) {
252
+ const run = await this.api.resource('bb_run', uuid);
253
+ const ticketUuid = run.rel('ticket_id');
254
+ if (!ticketUuid) {
255
+ return '';
256
+ }
257
+ const ticket = await this.api.resource('bb_ticket', ticketUuid);
258
+ return ticket.attr('identifier') ?? '';
259
+ }
260
+ async getRunTicketBranchName(uuid) {
261
+ const run = await this.api.resource('bb_run', uuid);
262
+ const ticketUuid = run.rel('ticket_id');
263
+ if (!ticketUuid) {
264
+ return '';
265
+ }
266
+ const ticket = await this.api.resource('bb_ticket', ticketUuid);
267
+ return ticket.attr('branch_name') ?? '';
268
+ }
269
+ /**
270
+ * POSTs a conductor registration to the heartbeat resource.
271
+ *
272
+ * Registration omits `current_load` so a startup ping cannot overwrite a
273
+ * live process's reported load. Heartbeat supplies load and lease.
274
+ */
275
+ async postHeartbeatRegistration(reg, extras = {}) {
276
+ return (await this.api.post('bakers/heartbeat', {
277
+ data: {
278
+ attributes: {
279
+ conductor_id: reg.id,
280
+ host_key: reg.host_key ?? reg.id,
281
+ project: reg.project,
282
+ states: reg.states,
283
+ workspace_root: reg.workspace,
284
+ label: reg.label,
285
+ max_parallel: reg.max_parallel,
286
+ ...processIdentity(reg),
287
+ ...(extras.current_load !== undefined
288
+ ? { current_load: extras.current_load }
289
+ : {}),
290
+ ...(extras.lease_seconds !== undefined
291
+ ? { lease_seconds: extras.lease_seconds }
292
+ : {}),
293
+ },
294
+ },
295
+ }));
296
+ }
297
+ async registerConductor(reg) {
298
+ const res = await this.postHeartbeatRegistration(reg);
299
+ const id = res?.data?.id;
300
+ if (typeof id !== 'string' || id === '') {
301
+ throw new Error('registerConductor: heartbeat response omitted data.id');
302
+ }
303
+ return id;
304
+ }
305
+ async heartbeat(reg, load, lease = 300) {
306
+ const res = await this.postHeartbeatRegistration(reg, {
307
+ current_load: load,
308
+ lease_seconds: lease,
309
+ });
310
+ const status = res?.data?.attributes?.status;
311
+ return typeof status === 'string' ? status : 'online';
312
+ }
313
+ async registerAddons(conductorId, definitions) {
314
+ let doc;
315
+ try {
316
+ doc = await this.api.post('bakers/addons', {
317
+ data: {
318
+ attributes: { conductor_id: conductorId, addons: definitions },
319
+ },
320
+ });
321
+ }
322
+ catch (err) {
323
+ // A 422 means every addon was rejected; its body still names why.
324
+ if (!(err instanceof HttpError) || err.status !== 422)
325
+ throw err;
326
+ doc = err.body;
327
+ }
328
+ const attrs = doc?.data?.attributes;
329
+ return { accepted: attrs?.accepted ?? [], rejected: attrs?.rejected ?? [] };
330
+ }
331
+ async fetchInbox(conductorId) {
332
+ const doc = (await this.api.get(`bakers/inbox/${encodeURIComponent(conductorId)}`));
333
+ const attrs = doc?.data?.attributes;
334
+ return {
335
+ messages: (attrs?.messages ?? []).map((m) => ({
336
+ id: m.id,
337
+ messageId: m.message_id,
338
+ version: m.version ?? '',
339
+ ticketUuid: m.ticket,
340
+ // PHP serialises an empty object as `[]`.
341
+ payload: Array.isArray(m.payload) ? {} : (m.payload ?? {}),
342
+ worktreePath: m.worktree_path ?? null,
343
+ requestedBy: m.requested_by ?? '',
344
+ })),
345
+ };
346
+ }
347
+ async writeInboxResult(messageUuid, status, result) {
348
+ await this.api.post(`bakers/inbox/${messageUuid}/result`, {
349
+ data: { attributes: { status, ...(result ? { result } : {}) } },
350
+ });
351
+ }
352
+ async getConductorStatus(id) {
353
+ const c = await this.api
354
+ .collection('bb_conductor')
355
+ .where('conductor_id', '=', id)
356
+ .fields(['status'])
357
+ .first();
358
+ return c ? (c.attr('status') ?? null) : null;
359
+ }
360
+ async setConductorStatus(id, status) {
361
+ if (status !== 'offline') {
362
+ throw new Error(`setConductorStatus: unsupported status "${status}"`);
363
+ }
364
+ const c = await this.api
365
+ .collection('bb_conductor')
366
+ .where('conductor_id', '=', id)
367
+ .first();
368
+ if (!c) {
369
+ return;
370
+ }
371
+ await this.api.post(`bb_conductor/${c.id}/offline`, {
372
+ data: { type: 'bb_conductor--bb_conductor', id: c.id },
373
+ });
374
+ }
375
+ async listConductors(owner) {
376
+ let col = this.api.collection('bb_conductor');
377
+ if (owner === 'me') {
378
+ col = col.where('owner_user_id.id', '=', await this.api.me());
379
+ }
380
+ const rows = await col.page(200).list();
381
+ // Resolve each distinct project once rather than per row: a machine
382
+ // typically runs several conductors of the same project.
383
+ const projectNames = new Map();
384
+ // Host capacity and host_key both live on bb_machine (BB-353):
385
+ // `bb_conductor` carries neither `max_parallel` nor `host_key` as a
386
+ // field — `filter[host_key]` against `bb_conductor` is a 400, the field
387
+ // does not exist there (BB-450 B7). Resolve both off the one related
388
+ // machine, once per distinct machine rather than once per row.
389
+ const machineDetails = new Map();
390
+ for (const r of rows) {
391
+ const projectUuid = r.rel('project_id');
392
+ if (projectUuid && !projectNames.has(projectUuid)) {
393
+ projectNames.set(projectUuid, await this.projectName(projectUuid));
394
+ }
395
+ const machineUuid = r.rel('machine');
396
+ if (machineUuid && !machineDetails.has(machineUuid)) {
397
+ machineDetails.set(machineUuid, await this.machineDetails(machineUuid));
398
+ }
399
+ }
400
+ return rows.map((r) => {
401
+ // Spread the optionals conditionally: an absent field must be absent,
402
+ // not present-and-undefined (exactOptionalPropertyTypes), and a row
403
+ // written before BB-232 legitimately carries neither.
404
+ const pid = r.attr('last_pid');
405
+ const name = r.attr('conductor_name');
406
+ // BB-450 AC-4/B7: host_key is the related bb_machine's field, not
407
+ // bb_conductor's — the host gate `signalVerifiedProcess()` needs had
408
+ // no input at all without resolving it through the relationship.
409
+ const machine = machineDetails.get(r.rel('machine') ?? '');
410
+ const hostKey = machine?.hostKey;
411
+ return {
412
+ id: r.attr('conductor_id') ??
413
+ r.attr('machine_id') ??
414
+ r.id,
415
+ project: projectNames.get(r.rel('project_id') ?? '') ?? '',
416
+ label: r.attr('label') ?? '',
417
+ status: r.attr('status') ?? '',
418
+ // ISO-8601 over the wire, not the unix integer the field name implies.
419
+ lastSeen: toUnixSeconds(r.attr('last_seen')) ?? 0,
420
+ // Process load telemetry; host capacity is on bb_machine (BB-353).
421
+ load: r.attr('reported_load') ??
422
+ r.attr('current_load') ??
423
+ 0,
424
+ workspaceRoot: r.attr('workspace_root') ?? '',
425
+ leaseExpiresAt: toUnixSeconds(r.attr('lease_expires_at')) ?? 0,
426
+ maxParallel: machine?.maxParallel ?? 0,
427
+ ...(typeof pid === 'number' ? { lastPid: pid } : {}),
428
+ ...(typeof name === 'string' && name !== ''
429
+ ? { conductorName: name }
430
+ : {}),
431
+ ...(typeof hostKey === 'string' && hostKey !== '' ? { hostKey } : {}),
432
+ };
433
+ });
434
+ }
435
+ async markRunning(uuid, attrs) {
436
+ const t = Math.floor(Date.now() / 1000);
437
+ // `bb_run.started_at` is a Drupal `timestamp` field: its normalizer
438
+ // accepts epoch seconds, ISO 8601 or RFC 3339, never a fractional-second
439
+ // string — so a caller's `Date`/ISO-string `startedAt` is normalised to
440
+ // whole epoch seconds here, same as `heartbeat`/`closed_date` right
441
+ // beside it (review round 4, Critical 1).
442
+ const startedAtSeconds = attrs?.startedAt === undefined
443
+ ? undefined
444
+ : typeof attrs.startedAt === 'number'
445
+ ? Math.floor(attrs.startedAt)
446
+ : Math.floor(new Date(attrs.startedAt).getTime() / 1000);
447
+ await this.api.update('bb_run', uuid, {
448
+ attributes: {
449
+ state: 'running',
450
+ heartbeat: t,
451
+ ...(attrs?.worktree_path ? { worktree_path: attrs.worktree_path } : {}),
452
+ ...(attrs?.agent ? { agent: attrs.agent } : {}),
453
+ ...(startedAtSeconds !== undefined && Number.isFinite(startedAtSeconds)
454
+ ? { started_at: startedAtSeconds }
455
+ : {}),
456
+ },
457
+ });
458
+ }
459
+ async markFailed(uuid, errorLog) {
460
+ const t = Math.floor(Date.now() / 1000);
461
+ await this.api.update('bb_run', uuid, {
462
+ attributes: {
463
+ state: 'failed',
464
+ error_log: errorLog,
465
+ closed: true,
466
+ closed_date: t,
467
+ },
468
+ });
469
+ }
470
+ async fetchFinalizableRuns(id) {
471
+ // `done` and `cancelled` finalise identically: stop the agent, capture its
472
+ // transcript, then close the run's terminal tab. Two queries are used
473
+ // because this builder has no `IN` operator in use.
474
+ const rows = [
475
+ ...(await this.finalizableRunsInState(id, 'done')),
476
+ ...(await this.finalizableRunsInState(id, 'cancelled')),
477
+ ];
478
+ // `started_at` is read back ONLY as a transcript-selection lower bound
479
+ // (BB-473, `AgentRunIdentity.startedAt`) for agents with no session-id
480
+ // mechanism (codex, opencode). It is NOT used to (re)compute duration_s,
481
+ // which stays derived from the transcript itself (BB-151).
482
+ return rows.map((r) => ({
483
+ runUuid: r.id,
484
+ // The `#<runId>` tab-label token: scopes the run's tab-close (BB-183).
485
+ runId: Number(r.attr('drupal_internal__id')),
486
+ // The `after_run` context (BB-599): durable, so it survives a restart.
487
+ ticketUuid: r.rel('ticket_id') ?? '',
488
+ stateAtStart: r.attr('state_at_start') ?? '',
489
+ worktreePath: r.attr('worktree_path') ?? '',
490
+ agent: r.attr('agent') ?? '',
491
+ startedAt: r.attr('started_at') ?? '',
492
+ }));
493
+ }
494
+ async finalizableRunsInState(id, state) {
495
+ return this.api
496
+ .collection('bb_run')
497
+ .where('conductor_id.conductor_id', '=', id)
498
+ .where('state', '=', state)
499
+ .where('closed', '=', '0')
500
+ .fields([
501
+ 'worktree_path',
502
+ 'agent',
503
+ 'drupal_internal__id',
504
+ 'started_at',
505
+ 'state_at_start',
506
+ 'ticket_id',
507
+ ])
508
+ .page(100)
509
+ .list();
510
+ }
511
+ async finalizeRun(uuid, log, metrics) {
512
+ const t = Math.floor(Date.now() / 1000);
513
+ const close = {
514
+ closed: true,
515
+ closed_date: t,
516
+ ...(metrics ?? {}),
517
+ };
518
+ try {
519
+ await this.api.update('bb_run', uuid, {
520
+ attributes: {
521
+ ...close,
522
+ ...(log ? { log } : {}),
523
+ },
524
+ }); // NO state — the run is already done.
525
+ }
526
+ catch (err) {
527
+ if (!(err instanceof RunLogSizeError)) {
528
+ throw err;
529
+ }
530
+ await this.closeOmittingOversizeLog(uuid, close);
531
+ }
532
+ }
533
+ /**
534
+ * Closes the run without the transcript after a recognized size rejection.
535
+ *
536
+ * A lost fallback response is reconciled by reading `closed` and must not
537
+ * send a third write.
538
+ */
539
+ async closeOmittingOversizeLog(uuid, close) {
540
+ try {
541
+ await this.api.update('bb_run', uuid, {
542
+ attributes: {
543
+ ...close,
544
+ log_upload_omitted: true,
545
+ },
546
+ });
547
+ }
548
+ catch (err) {
549
+ if (err instanceof HttpError && err.status !== 0) {
550
+ throw err;
551
+ }
552
+ if (await this.runIsClosed(uuid)) {
553
+ return;
554
+ }
555
+ throw err;
556
+ }
557
+ }
558
+ async runIsClosed(uuid) {
559
+ try {
560
+ const run = await this.api.resource('bb_run', uuid);
561
+ return Boolean(run.attr('closed'));
562
+ }
563
+ catch {
564
+ return false;
565
+ }
566
+ }
567
+ async fetchUncleanedTickets(id) {
568
+ // Closed tickets whose worktree is not yet torn down, scoped to this
569
+ // conductor (BB-121): only tickets assigned to `id` are loaded, so a
570
+ // ticket owned by another conductor never reaches the reaper loop. Since
571
+ // BB-451 the server closes every terminal ticket on its own save
572
+ // (BbTicket::preSave()), so this asks one question — closed but not
573
+ // cleaned up — with no workflow-state reasoning left in it. `cleaned_up`
574
+ // is nullable and an unwritten Drupal boolean is NULL, and `NULL = 0`
575
+ // matches nothing in SQL, so the NULL twin is a second query rather than a
576
+ // looser operator (BB-364 D1, absorbed here). The collection builder has
577
+ // no OR across DIFFERENT conditions on the same field, so the two stay
578
+ // separate AND-queries merged + de-duped by uuid. The `conductor_id`
579
+ // relationship carries the assigned conductor's `machine_id` — the same
580
+ // nested filter the sibling reaper queries (`fetchFinalizableRuns` etc.)
581
+ // use.
582
+ const fields = ['branch_name', 'state', 'base_branch', 'links'];
583
+ const uncleaned = await this.api
584
+ .collection('bb_ticket')
585
+ .where('conductor_id.conductor_id', '=', id)
586
+ .where('closed', '=', '1')
587
+ .where('cleaned_up', '=', '0')
588
+ .fields(fields)
589
+ .page(100)
590
+ .list();
591
+ const neverWritten = await this.api
592
+ .collection('bb_ticket')
593
+ .where('conductor_id.conductor_id', '=', id)
594
+ .where('closed', '=', '1')
595
+ .notExists('cleaned_up')
596
+ .fields(fields)
597
+ .page(100)
598
+ .list();
599
+ const byUuid = new Map();
600
+ for (const r of [...uncleaned, ...neverWritten]) {
601
+ byUuid.set(r.id, r);
602
+ }
603
+ return Promise.all([...byUuid.values()].map(async (r) => ({
604
+ ticketUuid: r.id,
605
+ branchName: r.attr('branch_name') ?? '',
606
+ state: r.attr('state') ?? '',
607
+ baseBranch: r.attr('base_branch') ?? '',
608
+ // Same rule as the server's LinkKind::fromUri(): the URI, not the title.
609
+ hasMr: (r.attr('links') ?? []).some((l) => (l.uri ?? '').includes('/-/merge_requests/')),
610
+ ...(await this.latestRun(r.id)),
611
+ })));
612
+ }
613
+ async fetchFrozenTickets(id) {
614
+ // Same conductor scope as the reaper. `frozen` is NOT NULL (the server
615
+ // stores a written null as FALSE), so one equality query is complete.
616
+ // ponytail: one page (JSON:API caps it at 50), sorted so the window is
617
+ // stable; past 50 frozen tickets per conductor the rest get no hooks.
618
+ // Follow links.next if a conductor ever holds that many.
619
+ const rows = await this.api
620
+ .collection('bb_ticket')
621
+ .where('conductor_id.conductor_id', '=', id)
622
+ .where('closed', '=', '0')
623
+ .where('frozen', '=', '1')
624
+ .fields(['identifier'])
625
+ .sort('drupal_internal__id')
626
+ .page(100)
627
+ .list();
628
+ return Promise.all(rows.map(async (r) => ({
629
+ ticketUuid: r.id,
630
+ identifier: r.attr('identifier') ?? r.id,
631
+ worktreePath: (await this.latestRun(r.id)).worktreePath,
632
+ })));
633
+ }
634
+ async replaceTicketMetrics(uuid, namespace, rows) {
635
+ const doc = (await this.api.get(`bb_ticket/bb_ticket/${uuid}?fields[bb_ticket--bb_ticket]=metrics`));
636
+ const kept = (doc.data?.attributes?.metrics ?? []).filter((m) => !m.name.startsWith(namespace));
637
+ await this.api.update('bb_ticket', uuid, {
638
+ attributes: { metrics: [...kept, ...rows] },
639
+ });
640
+ }
641
+ async markTicketCleanedUp(uuid) {
642
+ await this.api.update('bb_ticket', uuid, {
643
+ attributes: { cleaned_up: true },
644
+ }); // NO state/closed — teardown flag only, decoupled from the lifecycle.
645
+ }
646
+ /**
647
+ * UUID + absolute worktree path of the ticket's latest run (highest run id
648
+ * with a non-empty worktree_path), or empty strings when none — the cwd the
649
+ * cleanup command runs in, plus the record its teardown is noted on
650
+ * (BB-293). The conductor persists worktree_path on markRunning, so a done
651
+ * ticket's run carries the path even after the run closed.
652
+ *
653
+ * The uuid rides along on the query that already resolves the path, so the
654
+ * reaper learns which run to write to at no extra round-trip.
655
+ */
656
+ async latestRun(ticketUuid) {
657
+ const rows = await this.api
658
+ .collection('bb_run')
659
+ .where('ticket_id.id', '=', ticketUuid)
660
+ .fields(['worktree_path', 'drupal_internal__id'])
661
+ .sort('-drupal_internal__id')
662
+ .page(100)
663
+ .list();
664
+ for (const r of rows) {
665
+ const path = r.attr('worktree_path');
666
+ if (path) {
667
+ return { runUuid: r.id, worktreePath: path };
668
+ }
669
+ }
670
+ return { runUuid: '', worktreePath: '' };
671
+ }
672
+ async appendRunNote(runUuid, note) {
673
+ if (!runUuid) {
674
+ return; // no run to record anything on
675
+ }
676
+ // Confirm the run exists first, exactly as before: an unreadable run must
677
+ // refuse rather than let `fetchRunLog` treat a 404 caused by a missing run
678
+ // the same way it treats a fresh run with no transcript yet.
679
+ const run = await this.api
680
+ .collection('bb_run')
681
+ .where('id', '=', runUuid)
682
+ .fields(['id'])
683
+ .first();
684
+ if (!run) {
685
+ // Refuse rather than guess. With no row read, `existing` below would be ''
686
+ // and the PATCH would REPLACE the transcript with this one diagnostic
687
+ // line — destroying exactly what the note exists to preserve. An
688
+ // unreadable run is the reaper's best-effort catch (it warns and still
689
+ // flags the ticket), never a silent truncation.
690
+ throw new Error(`bb_run ${runUuid} could not be read — run note not written`);
691
+ }
692
+ // BB-556: the JSON:API `log` attribute is ALWAYS empty on a reload —
693
+ // `BbRun::preSave()` moves any payload to a file and clears the field —
694
+ // so the real current transcript can only be read through the download
695
+ // route. A run with no transcript yet (or whose file 404s) reads as ''.
696
+ const existing = await this.fetchRunLog(runUuid);
697
+ await this.api.update('bb_run', runUuid, {
698
+ attributes: { log: existing ? `${existing}\n${note}` : note },
699
+ }); // NO state/closed — the run is already closed.
700
+ }
701
+ async resolveTicketByIdentifier(project, identifier) {
702
+ // Scope by the project uuid: identifiers (bakers.build-nnn) are unique per project,
703
+ // not globally, so a bare identifier filter could match another project's
704
+ // ticket in a multi-project instance.
705
+ const projectId = await this.projectUuid(project);
706
+ const t = await this.api
707
+ .collection('bb_ticket')
708
+ .where('identifier', '=', identifier)
709
+ .where('project_id.id', '=', projectId)
710
+ .first();
711
+ if (!t) {
712
+ return null;
713
+ }
714
+ return { uuid: t.id, title: t.attr('title') ?? '' };
715
+ }
716
+ projectUuidCache = new Map();
717
+ /**
718
+ * The project *name* behind a project uuid — the inverse of projectUuid.
719
+ *
720
+ * A conductor row references its project; the CLI and the cockpit want to
721
+ * show the name. Best-effort: an unreadable project must not fail a listing,
722
+ * so it degrades to '' and the row still renders.
723
+ */
724
+ async projectName(uuid) {
725
+ try {
726
+ const p = await this.api.resource('bb_project', uuid);
727
+ return p.attr('name') ?? '';
728
+ }
729
+ catch {
730
+ return '';
731
+ }
732
+ }
733
+ /**
734
+ * Host concurrent-run budget and `host_key` for a machine uuid (BB-353,
735
+ * BB-450). Both live on `bb_machine`, never on `bb_conductor`.
736
+ *
737
+ * Best-effort: an unreadable or missing machine degrades to
738
+ * `{maxParallel: 0, hostKey: ''}` so the row still lists; capacity and
739
+ * identity are never invented from a conductor field.
740
+ */
741
+ async machineDetails(uuid) {
742
+ try {
743
+ const m = await this.api.resource('bb_machine', uuid);
744
+ return {
745
+ maxParallel: m.attr('max_parallel') ?? 0,
746
+ hostKey: m.attr('host_key') ?? '',
747
+ };
748
+ }
749
+ catch {
750
+ return { maxParallel: 0, hostKey: '' };
751
+ }
752
+ }
753
+ async projectUuid(name) {
754
+ // A project's name→uuid never changes, so cache it: gatherBatch resolves
755
+ // many identifiers per `bakers deployment tickets` call, each of which would
756
+ // otherwise re-fetch the same project.
757
+ const cached = this.projectUuidCache.get(name);
758
+ if (cached) {
759
+ return cached;
760
+ }
761
+ if (this.selection?.project === name && this.resolvedProjectId) {
762
+ this.projectUuidCache.set(name, this.resolvedProjectId);
763
+ return this.resolvedProjectId;
764
+ }
765
+ if (this.selection?.project === name) {
766
+ const context = await resolveBbProjectContext(this.api, this.selection);
767
+ this.projectUuidCache.set(name, context.projectId);
768
+ return context.projectId;
769
+ }
770
+ // BB-472: bb_project.name carries no uniqueness constraint, so a plain
771
+ // `.first()` was an arbitrary pick with no error and no log line. Read up
772
+ // to 2 rows instead — enough to detect "more than one" without paging the
773
+ // whole table — and fail loudly rather than guess.
774
+ const rows = await this.api
775
+ .collection('bb_project')
776
+ .where('name', '=', name)
777
+ .page(2)
778
+ .list();
779
+ if (rows.length === 0) {
780
+ throw new Error(`bakers project "${name}" not found`);
781
+ }
782
+ if (rows.length > 1) {
783
+ throw new AmbiguousProjectNameError(`bakers project "${name}" is ambiguous: at least ${rows.length} projects share this name. Name a workspace to disambiguate.`);
784
+ }
785
+ const id = rows[0]?.id ?? '';
786
+ this.projectUuidCache.set(name, id);
787
+ return id;
788
+ }
789
+ }
790
+ export function drupalRemote() {
791
+ return {
792
+ kind: 'remote',
793
+ id: 'drupal',
794
+ requiredModules: [],
795
+ async createRemote(config) {
796
+ // BB-472: build the client through the SAME connection plugin chain
797
+ // every other CLI surface runs (auth + alterRequest over
798
+ // `config.plugins`), instead of assembling a second, plugin-less client
799
+ // here. Without this, a configured `@bakers-build/addon-workspace` (or any
800
+ // other alterRequest plugin) was inert for the conductor's own remote —
801
+ // a configuration that reads correctly and does nothing.
802
+ const context = await createBbConnectionContext({
803
+ site: config.site,
804
+ plugins: config.plugins ?? [],
805
+ }, {
806
+ http: createRunLogHttp(config.site.base_url, config.site.jsonapi_prefix),
807
+ });
808
+ return new DrupalBbRemote(context.client, config.bakers, config.resolved_bakers?.projectId, createRunLogFetcher(context.http, context.auth, context.baseUrl));
809
+ },
810
+ };
811
+ }
812
+ export default drupalRemote;
@@ -0,0 +1,3 @@
1
+ import type { Preset } from '@bakers-build/conductor/contract';
2
+ export declare const remotes: Preset['remotes'];
3
+ export declare const scopes: Preset['scopes'];
@@ -0,0 +1,4 @@
1
+ import { drupalRemote } from './index.js';
2
+ export const remotes = (acc) => [...acc, drupalRemote()];
3
+ // Conductor wiring of one project: list this addon in .bakers/conductor.config.js addons[].
4
+ export const scopes = ['project'];
@@ -0,0 +1,14 @@
1
+ import type { HttpClient } from 'dropsh/plugin';
2
+ import { HttpError } from 'dropsh/plugin';
3
+ /**
4
+ * 413 for a locally oversize run-log JSON body, a recognized PHP size
5
+ * warning, or an HTTP 413 on a matching run POST/PATCH.
6
+ */
7
+ export declare class RunLogSizeError extends HttpError {
8
+ constructor(message: string, body?: unknown);
9
+ }
10
+ /**
11
+ * HTTP client that gzips JSON:API POST/PATCH bodies with a nonempty log or
12
+ * error_log, using dropsh's GET retries and no-retry writes.
13
+ */
14
+ export declare function createRunLogHttp(baseUrl: string, jsonapiPrefix: string): HttpClient;
@@ -0,0 +1,140 @@
1
+ import { promisify } from 'node:util';
2
+ import { gzip } from 'node:zlib';
3
+ import { createHttpClient, HttpError } from 'dropsh/plugin';
4
+ const gzipAsync = promisify(gzip);
5
+ /** Expanded UTF-8 JSON ceiling checked before compression. */
6
+ const MAX_EXPANDED_JSON_BYTES = 64 * 1024 * 1024;
7
+ const PHP_SIZE_WARNING = /POST Content-Length of \d+ bytes exceeds the limit of \d+ bytes/;
8
+ /**
9
+ * 413 for a locally oversize run-log JSON body, a recognized PHP size
10
+ * warning, or an HTTP 413 on a matching run POST/PATCH.
11
+ */
12
+ export class RunLogSizeError extends HttpError {
13
+ constructor(message, body = null) {
14
+ super(413, message, body);
15
+ this.name = 'RunLogSizeError';
16
+ }
17
+ }
18
+ /**
19
+ * HTTP client that gzips JSON:API POST/PATCH bodies with a nonempty log or
20
+ * error_log, using dropsh's GET retries and no-retry writes.
21
+ */
22
+ export function createRunLogHttp(baseUrl, jsonapiPrefix) {
23
+ const reads = createHttpClient();
24
+ const writes = createHttpClient({ maxRetries: 0 });
25
+ return {
26
+ async send(request) {
27
+ const transport = request.method === 'GET' ? reads : writes;
28
+ const outgoing = await maybeCompress(request, baseUrl, jsonapiPrefix);
29
+ try {
30
+ const res = await transport.send(outgoing);
31
+ throwIfPhpSizeWarning(request.method, res.body);
32
+ return res;
33
+ }
34
+ catch (err) {
35
+ if (err instanceof RunLogSizeError)
36
+ throw err;
37
+ if (err instanceof HttpError) {
38
+ throwIfPhpSizeWarning(request.method, typeof err.body === 'string' ? err.body : '');
39
+ if (err.status === 413 &&
40
+ (request.method === 'POST' || request.method === 'PATCH') &&
41
+ isRunWriteUrl(request.url, baseUrl, jsonapiPrefix, request.method)) {
42
+ throw new RunLogSizeError('Run log upload exceeded the server body size limit', err.body);
43
+ }
44
+ }
45
+ throw err;
46
+ }
47
+ },
48
+ };
49
+ }
50
+ async function maybeCompress(request, baseUrl, jsonapiPrefix) {
51
+ if (request.method !== 'POST' && request.method !== 'PATCH') {
52
+ return request;
53
+ }
54
+ if (!isRunWriteUrl(request.url, baseUrl, jsonapiPrefix, request.method)) {
55
+ return request;
56
+ }
57
+ if (typeof request.body !== 'string') {
58
+ return request;
59
+ }
60
+ if (hasContentEncoding(request.headers)) {
61
+ return request;
62
+ }
63
+ if (!hasNonemptyRunLog(request.body)) {
64
+ return request;
65
+ }
66
+ if (Buffer.byteLength(request.body, 'utf8') > MAX_EXPANDED_JSON_BYTES) {
67
+ throw new RunLogSizeError('Run log JSON exceeds the 64 MiB expanded size limit');
68
+ }
69
+ const compressed = await gzipAsync(Buffer.from(request.body, 'utf8'), {
70
+ level: 6,
71
+ });
72
+ const headers = new Headers(request.headers);
73
+ headers.set('Content-Encoding', 'gzip');
74
+ headers.delete('Content-Length');
75
+ return {
76
+ ...request,
77
+ headers: Object.fromEntries(headers),
78
+ body: compressed,
79
+ };
80
+ }
81
+ function isRunWriteUrl(url, baseUrl, jsonapiPrefix, method) {
82
+ let parsed;
83
+ let base;
84
+ try {
85
+ parsed = new URL(url);
86
+ base = new URL(baseUrl);
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ if (parsed.origin !== base.origin)
92
+ return false;
93
+ const prefix = jsonapiPrefix.startsWith('/')
94
+ ? jsonapiPrefix
95
+ : `/${jsonapiPrefix}`;
96
+ const collection = `${base.pathname.replace(/\/+$/, '')}${prefix.replace(/\/+$/, '')}/bb_run/bb_run`;
97
+ const path = parsed.pathname.replace(/\/+$/, '') || '/';
98
+ if (method === 'POST') {
99
+ return path === collection;
100
+ }
101
+ if (!path.startsWith(`${collection}/`))
102
+ return false;
103
+ const id = path.slice(collection.length + 1);
104
+ return id.length > 0 && !id.includes('/');
105
+ }
106
+ function hasContentEncoding(headers) {
107
+ if (!headers)
108
+ return false;
109
+ const encoding = new Headers(headers).get('content-encoding');
110
+ return encoding !== null && encoding.trim() !== '';
111
+ }
112
+ function hasNonemptyRunLog(body) {
113
+ try {
114
+ const parsed = JSON.parse(body);
115
+ if (!parsed || typeof parsed !== 'object')
116
+ return false;
117
+ const data = parsed.data;
118
+ if (!data || typeof data !== 'object')
119
+ return false;
120
+ const attributes = data.attributes;
121
+ if (!attributes || typeof attributes !== 'object')
122
+ return false;
123
+ const attrs = attributes;
124
+ return isNonemptyString(attrs.log) || isNonemptyString(attrs.error_log);
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ function isNonemptyString(value) {
131
+ return typeof value === 'string' && value.length > 0;
132
+ }
133
+ function throwIfPhpSizeWarning(method, raw) {
134
+ if (method !== 'POST' && method !== 'PATCH') {
135
+ return;
136
+ }
137
+ if (PHP_SIZE_WARNING.test(raw)) {
138
+ throw new RunLogSizeError('Run log upload exceeded the server body size limit');
139
+ }
140
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-remote-drupal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.38",
4
+ "description": "bakers.build conductor remote addon: the Drupal/JSON:API control-plane remote.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js",
9
+ "./preset": "./dist/src/preset.js"
10
+ },
11
+ "files": [
12
+ "dist/src"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
20
+ "directory": "bakers-cli/addons/remote-drupal"
21
+ },
22
+ "dependencies": {
23
+ "dropsh": "^0.7.5"
24
+ },
25
+ "peerDependencies": {
26
+ "@bakers-build/conductor": "^0.1.0-next.38",
27
+ "@bakers-build/core": "^0.1.0-next.38",
28
+ "@bakers-build/message": "^0.1.0-next.38"
29
+ }
30
+ }