@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 +21 -0
- package/README.md +4 -2
- package/dist/src/index.d.ts +121 -0
- package/dist/src/index.js +812 -0
- package/dist/src/preset.d.ts +3 -0
- package/dist/src/preset.js +4 -0
- package/dist/src/run-log-http.d.ts +14 -0
- package/dist/src/run-log-http.js +140 -0
- package/package.json +28 -4
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
|
-
#
|
|
1
|
+
# @bakers-build/addon-remote-drupal
|
|
2
2
|
|
|
3
|
-
|
|
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,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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|