@loucompanion/forge-bridge 0.1.1-dev.caea7216568f → 0.1.1-dev.d71662695396

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.
@@ -15,6 +15,7 @@ export declare class ForgeBridgeService implements BridgeService {
15
15
  run(signal?: AbortSignal): Promise<void>;
16
16
  stop(): Promise<void>;
17
17
  private handleDispatch;
18
+ private parseDispatchOrReport;
18
19
  private handleCancel;
19
20
  private abortActiveSessions;
20
21
  private normalizeDispatch;
@@ -1,4 +1,5 @@
1
1
  import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { defaultForgeBridgeConfig } from "../config.js";
4
5
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
@@ -48,8 +49,14 @@ export class ForgeBridgeService {
48
49
  while (!this.stopped && !signal?.aborted) {
49
50
  try {
50
51
  await this.connectOnce();
52
+ if (attempt > 0) {
53
+ logBridgeStatus("Connected to Forge. Waiting for dispatches...");
54
+ }
51
55
  attempt = 0;
52
56
  await this.waitForClose(signal);
57
+ if (!this.stopped && !signal?.aborted) {
58
+ logBridgeStatus("Connection closed. Reconnecting...");
59
+ }
53
60
  }
54
61
  catch (error) {
55
62
  if (this.stopped || signal?.aborted) {
@@ -57,6 +64,7 @@ export class ForgeBridgeService {
57
64
  }
58
65
  const backoff = this.config.reconnectBackoffMs[Math.min(attempt, this.config.reconnectBackoffMs.length - 1)] ?? 5000;
59
66
  attempt += 1;
67
+ logBridgeStatus(`Connect attempt ${attempt} failed: ${errorMessage(error)}. Retrying in ${formatDelay(backoff)}...`);
60
68
  await delay(backoff, signal);
61
69
  }
62
70
  }
@@ -73,10 +81,14 @@ export class ForgeBridgeService {
73
81
  this.abortActiveSessions("bridge stopping");
74
82
  await this.stopConnection();
75
83
  }
76
- async handleDispatch(dispatch) {
84
+ async handleDispatch(rawDispatch) {
85
+ const dispatch = this.parseDispatchOrReport(rawDispatch);
86
+ if (!dispatch) {
87
+ return;
88
+ }
77
89
  const leaseId = dispatch.lease.id;
78
90
  const fencingToken = dispatch.lease.fencingToken;
79
- const redactionSecrets = exactSecrets(dispatch);
91
+ const redactionSecrets = exactSecrets(dispatch, this.config);
80
92
  if (this.activeSessions.has(leaseId)) {
81
93
  await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
82
94
  return;
@@ -87,11 +99,20 @@ export class ForgeBridgeService {
87
99
  const normalized = this.normalizeDispatch(dispatch);
88
100
  const result = await this.sessions.runOnce(normalized, {
89
101
  emit: async (event) => {
90
- await this.invokeAck("DispatchEvent", {
91
- leaseId,
92
- fencingToken,
93
- event: redactSecrets(event, redactionSecrets)
94
- });
102
+ try {
103
+ await this.invokeAck("DispatchEvent", {
104
+ leaseId,
105
+ fencingToken,
106
+ event: redactSecrets(event, redactionSecrets)
107
+ });
108
+ }
109
+ catch (error) {
110
+ if (isDispatchNoLongerPending(error)) {
111
+ controller.abort(error instanceof Error ? error.message : "Dispatch is no longer pending.");
112
+ return;
113
+ }
114
+ throw error;
115
+ }
95
116
  }
96
117
  }, controller.signal);
97
118
  await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
@@ -106,6 +127,27 @@ export class ForgeBridgeService {
106
127
  this.activeSessions.delete(leaseId);
107
128
  }
108
129
  }
130
+ parseDispatchOrReport(rawDispatch) {
131
+ try {
132
+ return parseRunOnceDispatchPacket(rawDispatch);
133
+ }
134
+ catch (error) {
135
+ const identifiers = readDispatchIdentifiers(rawDispatch);
136
+ if (!identifiers) {
137
+ return null;
138
+ }
139
+ const summary = invalidDispatchSummary(error);
140
+ void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
141
+ {
142
+ role: "runner",
143
+ kind: "error",
144
+ content: summary,
145
+ eventType: "invalid_dispatch"
146
+ }
147
+ ])).catch(() => undefined);
148
+ return null;
149
+ }
150
+ }
109
151
  async handleCancel(request) {
110
152
  const active = this.activeSessions.get(request.leaseId);
111
153
  if (!active || active.fencingToken !== request.fencingToken) {
@@ -120,9 +162,10 @@ export class ForgeBridgeService {
120
162
  this.activeSessions.clear();
121
163
  }
122
164
  normalizeDispatch(dispatch) {
123
- const mapping = findRepoMapping(dispatch, this.config.repoMappings);
165
+ const mapping = findRepoMapping(dispatch, repoMappingsFor(this.config));
124
166
  const localPath = mapping?.localPath?.trim();
125
167
  const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
168
+ const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
126
169
  return {
127
170
  ...dispatch,
128
171
  repo: {
@@ -133,7 +176,13 @@ export class ForgeBridgeService {
133
176
  serverLocal: {
134
177
  repoRoot,
135
178
  cleanupWorktrees: this.config.cleanupWorktrees
136
- }
179
+ },
180
+ qaBrowser: dispatch.qaBrowser
181
+ ? {
182
+ ...dispatch.qaBrowser,
183
+ launchTarget: qaLaunchTarget
184
+ }
185
+ : dispatch.qaBrowser
137
186
  };
138
187
  }
139
188
  async sendResult(leaseId, fencingToken, result) {
@@ -142,7 +191,15 @@ export class ForgeBridgeService {
142
191
  fencingToken,
143
192
  result
144
193
  };
145
- await this.invokeAck("DispatchResult", payload);
194
+ try {
195
+ await this.invokeAck("DispatchResult", payload);
196
+ }
197
+ catch (error) {
198
+ if (isDispatchNoLongerPending(error)) {
199
+ return;
200
+ }
201
+ throw error;
202
+ }
146
203
  }
147
204
  startHeartbeat() {
148
205
  this.stopHeartbeat();
@@ -173,13 +230,14 @@ export class ForgeBridgeService {
173
230
  cliProviders: this.config.cliProviders,
174
231
  providerStatuses: this.config.providerStatuses,
175
232
  projectLabels: this.config.projectLabels,
176
- repoMappings: this.config.repoMappings,
233
+ repoMappings: repoMappingsFor(this.config),
177
234
  os: this.config.os,
178
235
  sandboxProfiles: this.config.sandboxProfiles,
179
236
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
180
237
  supportsManagedWorktrees: this.config.supportsManagedWorktrees,
181
238
  supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
182
- supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts
239
+ supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
240
+ supportsDraftRunOnce: this.config.supportsDraftRunOnce
183
241
  };
184
242
  }
185
243
  async invokeAck(methodName, payload) {
@@ -189,7 +247,7 @@ export class ForgeBridgeService {
189
247
  }
190
248
  const ack = await connection.invoke(methodName, payload);
191
249
  if (!ack.accepted) {
192
- throw new Error(ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
250
+ throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
193
251
  }
194
252
  return ack;
195
253
  }
@@ -207,6 +265,20 @@ export class ForgeBridgeService {
207
265
  }
208
266
  }
209
267
  }
268
+ class BridgeCallbackRejectedError extends Error {
269
+ code;
270
+ constructor(code, message) {
271
+ super(message);
272
+ this.code = code;
273
+ this.name = "BridgeCallbackRejectedError";
274
+ }
275
+ }
276
+ function isDispatchNoLongerPending(error) {
277
+ if (error instanceof BridgeCallbackRejectedError) {
278
+ return error.code === "bridge.dispatch_not_pending" || error.code === "bridge.stale_dispatch";
279
+ }
280
+ return error instanceof Error && error.message === "Dispatch is no longer pending.";
281
+ }
210
282
  export class SignalRBridgeConnectionFactory {
211
283
  create(config) {
212
284
  return new SignalRBridgeConnection(config);
@@ -237,14 +309,7 @@ export class SignalRBridgeConnection {
237
309
  }
238
310
  onDispatch(handler) {
239
311
  this.connection.on("Dispatch", (raw) => {
240
- let dispatch;
241
- try {
242
- dispatch = parseRunOnceDispatchPacket(raw);
243
- }
244
- catch {
245
- return;
246
- }
247
- void handler(dispatch).catch(() => undefined);
312
+ void handler(raw).catch(() => undefined);
248
313
  });
249
314
  }
250
315
  onCancel(handler) {
@@ -271,11 +336,12 @@ export function defaultBridgeServiceConfig(overrides = {}) {
271
336
  projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
272
337
  repoMappings: [],
273
338
  os: process.platform,
274
- sandboxProfiles: ["default"],
339
+ sandboxProfiles: overrides.sandboxProfiles,
275
340
  supportsApiSecretGrant: true,
276
341
  supportsManagedWorktrees: true,
277
342
  supportsManagedForgeArtifacts: true,
278
343
  supportsRunnerAuthoredPhaseArtifacts: true,
344
+ supportsDraftRunOnce: true,
279
345
  version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
280
346
  protocolVersion: 1,
281
347
  minServerVersion: null,
@@ -285,6 +351,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
285
351
  };
286
352
  return {
287
353
  ...config,
354
+ sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
288
355
  heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
289
356
  };
290
357
  }
@@ -303,6 +370,14 @@ function positiveFiniteNumber(value, fallback) {
303
370
  function providerStatusesFor(providers) {
304
371
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
305
372
  }
373
+ function defaultSandboxProfiles(providers, platform = process.platform) {
374
+ if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
375
+ return ["none"];
376
+ }
377
+ return platform === "win32"
378
+ ? ["danger-full-access"]
379
+ : ["provider-workspace-write"];
380
+ }
306
381
  function configuredModelsFor(provider) {
307
382
  const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
308
383
  return splitCsv(process.env[envName] ?? "");
@@ -313,6 +388,62 @@ function splitCsv(value) {
313
388
  .map((entry) => entry.trim())
314
389
  .filter((entry) => entry.length > 0);
315
390
  }
391
+ function repoMappingsFor(config) {
392
+ const explicit = [...config.repoMappings];
393
+ const discovered = discoverRepoRootMappings(config.defaultRepoRoot);
394
+ if (explicit.length === 0) {
395
+ return discovered;
396
+ }
397
+ const explicitKeys = new Set(explicit.flatMap(mappingKeys));
398
+ return [
399
+ ...explicit,
400
+ ...discovered.filter((mapping) => !mappingKeys(mapping).some((key) => explicitKeys.has(key)))
401
+ ];
402
+ }
403
+ function discoverRepoRootMappings(defaultRepoRoot) {
404
+ const root = defaultRepoRoot.trim();
405
+ if (!root) {
406
+ return [];
407
+ }
408
+ const resolvedRoot = path.resolve(root);
409
+ const mappings = [];
410
+ if (isGitCheckout(resolvedRoot)) {
411
+ mappings.push({ name: path.basename(resolvedRoot), localPath: resolvedRoot });
412
+ }
413
+ let entries;
414
+ try {
415
+ entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
416
+ }
417
+ catch {
418
+ return mappings;
419
+ }
420
+ for (const entry of entries) {
421
+ if (!entry.isDirectory()) {
422
+ continue;
423
+ }
424
+ const localPath = path.join(resolvedRoot, entry.name);
425
+ if (isGitCheckout(localPath)) {
426
+ mappings.push({ name: entry.name, localPath });
427
+ }
428
+ }
429
+ return mappings.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
430
+ }
431
+ function isGitCheckout(localPath) {
432
+ try {
433
+ return fs.existsSync(path.join(localPath, ".git"));
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ }
439
+ function mappingKeys(mapping) {
440
+ return [
441
+ mapping.repoId ? `id:${mapping.repoId.toLowerCase()}` : null,
442
+ mapping.name ? `name:${mapping.name.trim().toLowerCase()}` : null,
443
+ mapping.remoteUrl ? `remote:${mapping.remoteUrl.trim().toLowerCase()}` : null,
444
+ mapping.localPath ? `path:${path.resolve(mapping.localPath).toLowerCase()}` : null
445
+ ].filter((key) => Boolean(key));
446
+ }
316
447
  function findRepoMapping(dispatch, mappings) {
317
448
  return mappings.find((mapping) => {
318
449
  if (!mapping.localPath?.trim()) {
@@ -323,16 +454,70 @@ function findRepoMapping(dispatch, mappings) {
323
454
  (!!mapping.remoteUrl?.trim() && equalsIgnoreCase(mapping.remoteUrl, dispatch.repo.remoteUrl)));
324
455
  });
325
456
  }
457
+ function normalizeQaAppLaunchTarget(target) {
458
+ if (!target) {
459
+ return null;
460
+ }
461
+ const routes = [...new Set((target.routes ?? []).map((route) => route.trim()).filter(Boolean))];
462
+ const normalized = {
463
+ url: cleanString(target.url),
464
+ command: cleanString(target.command),
465
+ working_directory: cleanString(target.working_directory),
466
+ ready_url: cleanString(target.ready_url),
467
+ routes,
468
+ notes: cleanString(target.notes)
469
+ };
470
+ return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
471
+ ? normalized
472
+ : null;
473
+ }
474
+ function cleanString(value) {
475
+ const clean = value?.trim();
476
+ return clean ? clean : null;
477
+ }
326
478
  function equalsIgnoreCase(left, right) {
327
479
  return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
328
480
  }
481
+ function readDispatchIdentifiers(rawDispatch) {
482
+ if (!rawDispatch || typeof rawDispatch !== "object" || Array.isArray(rawDispatch)) {
483
+ return null;
484
+ }
485
+ const packet = rawDispatch;
486
+ const rawLease = packet.lease ?? packet.Lease;
487
+ if (!rawLease || typeof rawLease !== "object" || Array.isArray(rawLease)) {
488
+ return null;
489
+ }
490
+ const lease = rawLease;
491
+ const leaseId = readString(lease, "id", "Id");
492
+ const fencingToken = readString(lease, "fencingToken", "FencingToken");
493
+ return leaseId && fencingToken ? { leaseId, fencingToken } : null;
494
+ }
495
+ function readString(source, camelKey, pascalKey) {
496
+ const value = source[camelKey] ?? source[pascalKey];
497
+ return typeof value === "string" && value.trim() ? value : null;
498
+ }
499
+ function invalidDispatchSummary(error) {
500
+ const detail = error instanceof Error ? error.message : String(error);
501
+ return `Invalid bridge dispatch packet: ${detail}`;
502
+ }
503
+ function errorMessage(error) {
504
+ return error instanceof Error ? error.message : String(error);
505
+ }
506
+ function formatDelay(ms) {
507
+ if (ms >= 1000 && ms % 1000 === 0) {
508
+ return `${ms / 1000}s`;
509
+ }
510
+ return `${ms}ms`;
511
+ }
512
+ function logBridgeStatus(message) {
513
+ process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
514
+ }
329
515
  function delay(ms, signal) {
330
516
  if (signal?.aborted) {
331
517
  return Promise.resolve();
332
518
  }
333
519
  return new Promise((resolve) => {
334
520
  const timeout = setTimeout(resolve, ms);
335
- timeout.unref?.();
336
521
  signal?.addEventListener("abort", () => {
337
522
  clearTimeout(timeout);
338
523
  resolve();
@@ -348,6 +533,9 @@ function abortPromise(signal) {
348
533
  }
349
534
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
350
535
  }
351
- function exactSecrets(dispatch) {
352
- return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
536
+ function exactSecrets(dispatch, config) {
537
+ return [
538
+ dispatch.apiKeySecretGrant?.secret,
539
+ config.deviceToken
540
+ ].filter((secret) => typeof secret === "string" && secret.length > 0);
353
541
  }
@@ -1,4 +1,4 @@
1
- import type { RunOnceDispatchPacket, RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
1
+ import type { RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
2
2
  export interface BridgeServiceConfig {
3
3
  readonly serverUrl: string;
4
4
  readonly runnerId: string;
@@ -16,6 +16,7 @@ export interface BridgeServiceConfig {
16
16
  readonly supportsManagedWorktrees: boolean;
17
17
  readonly supportsManagedForgeArtifacts: boolean;
18
18
  readonly supportsRunnerAuthoredPhaseArtifacts: boolean;
19
+ readonly supportsDraftRunOnce: boolean;
19
20
  readonly version: string;
20
21
  readonly protocolVersion: number;
21
22
  readonly minServerVersion?: string | null;
@@ -27,6 +28,15 @@ export interface BridgeRepoMapping {
27
28
  readonly name?: string | null;
28
29
  readonly remoteUrl?: string | null;
29
30
  readonly localPath?: string | null;
31
+ readonly qaAppLaunchTarget?: QaAppLaunchTarget | null;
32
+ }
33
+ export interface QaAppLaunchTarget {
34
+ readonly url?: string | null;
35
+ readonly command?: string | null;
36
+ readonly working_directory?: string | null;
37
+ readonly ready_url?: string | null;
38
+ readonly routes?: readonly string[] | null;
39
+ readonly notes?: string | null;
30
40
  }
31
41
  export interface BridgeProviderStatus {
32
42
  readonly signedIn?: boolean | null;
@@ -46,6 +56,7 @@ export interface BridgeCapabilities {
46
56
  readonly supportsManagedWorktrees?: boolean | null;
47
57
  readonly supportsManagedForgeArtifacts?: boolean | null;
48
58
  readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
59
+ readonly supportsDraftRunOnce?: boolean | null;
49
60
  }
50
61
  export interface BridgeHelloRequest {
51
62
  readonly version: string;
@@ -79,5 +90,5 @@ export interface BridgeCancelRequest {
79
90
  readonly fencingToken: string;
80
91
  readonly reason?: string | null;
81
92
  }
82
- export type BridgeDispatchHandler = (dispatch: RunOnceDispatchPacket) => Promise<void>;
93
+ export type BridgeDispatchHandler = (dispatch: unknown) => Promise<void>;
83
94
  export type BridgeCancelHandler = (request: BridgeCancelRequest) => Promise<void>;