@wenrwa/marketplace-cli 0.8.11 → 0.8.13

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.
Files changed (29) hide show
  1. package/dist/__tests__/bridge-cleanup-frame.test.d.ts +1 -0
  2. package/dist/__tests__/bridge-cleanup-frame.test.js +217 -0
  3. package/dist/__tests__/bridge-clone-grow-quote.test.d.ts +1 -0
  4. package/dist/__tests__/bridge-clone-grow-quote.test.js +145 -0
  5. package/dist/__tests__/bridge-disk-pressure-frame.test.d.ts +1 -0
  6. package/dist/__tests__/bridge-disk-pressure-frame.test.js +179 -0
  7. package/dist/__tests__/bridge-preview-checkout.test.js +9 -0
  8. package/dist/__tests__/bridge-storage-cleanup-real-fs.test.d.ts +1 -0
  9. package/dist/__tests__/bridge-storage-cleanup-real-fs.test.js +372 -0
  10. package/dist/__tests__/git-token-refresh.test.d.ts +1 -0
  11. package/dist/__tests__/git-token-refresh.test.js +231 -0
  12. package/dist/bridge/diskPreflight.d.ts +4 -1
  13. package/dist/bridge/diskPreflight.js +5 -3
  14. package/dist/bridge/gitTokenBroker.d.ts +28 -0
  15. package/dist/bridge/gitTokenBroker.js +124 -0
  16. package/dist/bridge/previewCheckout.d.ts +11 -0
  17. package/dist/bridge/previewCheckout.js +18 -1
  18. package/dist/bridge/relayClient.d.ts +47 -0
  19. package/dist/bridge/relayClient.js +150 -2
  20. package/dist/bridge/repoCheckout.d.ts +3 -1
  21. package/dist/bridge/repoCheckout.js +6 -1
  22. package/dist/bridge/storageCleanup.d.ts +66 -0
  23. package/dist/bridge/storageCleanup.js +337 -0
  24. package/dist/bridge/storageQuotaFetch.js +12 -0
  25. package/dist/bridge/storageQuotaPreflight.d.ts +12 -0
  26. package/dist/commands/bridge.d.ts +10 -0
  27. package/dist/commands/bridge.js +59 -3
  28. package/dist/index.js +0 -0
  29. package/package.json +1 -1
@@ -0,0 +1,28 @@
1
+ export interface GitCredential {
2
+ token: string;
3
+ repoFullName: string;
4
+ expiresAt: string;
5
+ }
6
+ export type GitCredentialFetcher = (workspaceId: string) => Promise<GitCredential>;
7
+ /** Ask again once a cached credential has less than this left. */
8
+ export declare const REFRESH_BEFORE_EXPIRY_MS: number;
9
+ export declare class GitTokenBroker {
10
+ private fetch;
11
+ private now;
12
+ private cache;
13
+ private inFlight;
14
+ constructor(fetch: GitCredentialFetcher, now?: () => number);
15
+ get(workspaceId: string): Promise<GitCredential>;
16
+ }
17
+ export interface GitTokenSocketHandle {
18
+ path: string;
19
+ close(): Promise<void>;
20
+ }
21
+ /**
22
+ * Serve `broker` on a unix socket at `opts.path` (0600). Replaces a stale
23
+ * socket file left by a previous bridge.
24
+ */
25
+ export declare function startGitTokenSocket(opts: {
26
+ path: string;
27
+ broker: GitTokenBroker;
28
+ }): Promise<GitTokenSocketHandle>;
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.GitTokenBroker = exports.REFRESH_BEFORE_EXPIRY_MS = void 0;
7
+ exports.startGitTokenSocket = startGitTokenSocket;
8
+ /**
9
+ * a9be9720 — fresh git credentials for a box's agents, for as long as they run.
10
+ *
11
+ * The relay puts a workspace's git token on each PTY's env once, at open
12
+ * (WENRWA_GIT_TOKEN). It is a GitHub App installation token and lives an
13
+ * hour; agent sessions live for days. So every session older than that held a
14
+ * dead credential and could not push — "remote: Invalid username or token",
15
+ * and no retry helped.
16
+ *
17
+ * Now `git-credential-wenrwa get` (infra/terminal-image) asks THIS bridge,
18
+ * over a unix socket, for a credential for $WENRWA_WORKSPACE_ID:
19
+ *
20
+ * GET /git-credential?workspace=<id> → 200 "username=…\npassword=…\n"
21
+ *
22
+ * The answer is already in git's credential-helper format, so the shell helper
23
+ * only has to print it. The bridge keeps each workspace's credential in memory
24
+ * until shortly before it expires, and otherwise asks the relay for a fresh
25
+ * one (`git-token`, relayClient.requestGitToken).
26
+ *
27
+ * Never on disk, never logged. The socket is 0600 and lives in the bridge
28
+ * user's home: every process that can reach it is the same user whose PTYs
29
+ * already carry these tokens on their env.
30
+ *
31
+ * Any failure answers non-2xx with a one-line reason, and the helper falls back
32
+ * to the injected token — so a bridge that cannot mint is never worse than
33
+ * before.
34
+ */
35
+ const fs_1 = __importDefault(require("fs"));
36
+ const http_1 = __importDefault(require("http"));
37
+ const path_1 = __importDefault(require("path"));
38
+ /** Ask again once a cached credential has less than this left. */
39
+ exports.REFRESH_BEFORE_EXPIRY_MS = 5 * 60_000;
40
+ class GitTokenBroker {
41
+ fetch;
42
+ now;
43
+ cache = new Map();
44
+ inFlight = new Map();
45
+ constructor(fetch, now = Date.now) {
46
+ this.fetch = fetch;
47
+ this.now = now;
48
+ }
49
+ async get(workspaceId) {
50
+ const cached = this.cache.get(workspaceId);
51
+ if (cached && Date.parse(cached.expiresAt) - this.now() > exports.REFRESH_BEFORE_EXPIRY_MS)
52
+ return cached;
53
+ // A burst of git calls (fetch + push, a submodule walk) makes ONE request.
54
+ const pending = this.inFlight.get(workspaceId);
55
+ if (pending)
56
+ return pending;
57
+ const request = this.fetch(workspaceId)
58
+ .then((cred) => {
59
+ this.cache.set(workspaceId, cred);
60
+ return cred;
61
+ })
62
+ .finally(() => this.inFlight.delete(workspaceId));
63
+ this.inFlight.set(workspaceId, request);
64
+ return request;
65
+ }
66
+ }
67
+ exports.GitTokenBroker = GitTokenBroker;
68
+ /** Workspace ids are UUIDs; anything else is refused before it is used. */
69
+ const WORKSPACE_ID = /^[A-Za-z0-9-]{1,64}$/;
70
+ /**
71
+ * Serve `broker` on a unix socket at `opts.path` (0600). Replaces a stale
72
+ * socket file left by a previous bridge.
73
+ */
74
+ async function startGitTokenSocket(opts) {
75
+ fs_1.default.mkdirSync(path_1.default.dirname(opts.path), { recursive: true, mode: 0o700 });
76
+ try {
77
+ fs_1.default.unlinkSync(opts.path);
78
+ }
79
+ catch {
80
+ /* no previous socket */
81
+ }
82
+ const server = http_1.default.createServer((req, res) => {
83
+ const reply = (status, body) => {
84
+ res.writeHead(status, { 'content-type': 'text/plain', 'cache-control': 'no-store' });
85
+ res.end(body);
86
+ };
87
+ const url = new URL(req.url ?? '/', 'http://bridge');
88
+ if (req.method !== 'GET' || url.pathname !== '/git-credential') {
89
+ reply(404, 'not found\n');
90
+ return;
91
+ }
92
+ const workspaceId = url.searchParams.get('workspace') ?? '';
93
+ if (!WORKSPACE_ID.test(workspaceId)) {
94
+ reply(400, 'no workspace: WENRWA_WORKSPACE_ID is not set in this environment\n');
95
+ return;
96
+ }
97
+ opts.broker.get(workspaceId).then((cred) => reply(200, `username=x-access-token\npassword=${cred.token}\n`), (err) => {
98
+ const e = err;
99
+ const code = typeof e?.code === 'string' ? e.code : 'GIT_TOKEN_FAILED';
100
+ const message = typeof e?.message === 'string' ? e.message : 'could not get a git token';
101
+ reply(502, `${code}: ${message}\n`);
102
+ });
103
+ });
104
+ await new Promise((resolve, reject) => {
105
+ server.once('error', reject);
106
+ server.listen(opts.path, () => {
107
+ server.off('error', reject);
108
+ resolve();
109
+ });
110
+ });
111
+ fs_1.default.chmodSync(opts.path, 0o600);
112
+ return {
113
+ path: opts.path,
114
+ close: () => new Promise((resolve) => {
115
+ server.close(() => resolve());
116
+ try {
117
+ fs_1.default.unlinkSync(opts.path);
118
+ }
119
+ catch {
120
+ /* already gone */
121
+ }
122
+ }),
123
+ };
124
+ }
@@ -153,7 +153,18 @@ export declare class PreviewRunner {
153
153
  /** One port, so one operation at a time across the whole box. */
154
154
  private chain;
155
155
  private orphanSwept;
156
+ /** Queued + running start/update/stop calls (stc-05's cleaner reads it via activity()). */
157
+ private opsInFlight;
156
158
  constructor(opts: PreviewRunnerOpts);
159
+ /**
160
+ * stc-05 — what storage cleanup must leave alone right now: any queued or
161
+ * running start/update/stop (`opInFlight` — it may be writing any slot),
162
+ * and every workspace with a preview building or serving.
163
+ */
164
+ activity(): {
165
+ opInFlight: boolean;
166
+ activeWorkspaces: string[];
167
+ };
157
168
  /** Bring the preview up. Already serving this workspace in this mode → answered as-is. */
158
169
  start(req: PreviewRequest): Promise<PreviewResult>;
159
170
  /** Fetch the default branch again and, when it moved, rebuild and swap. */
@@ -363,6 +363,8 @@ class PreviewRunner {
363
363
  /** One port, so one operation at a time across the whole box. */
364
364
  chain = Promise.resolve();
365
365
  orphanSwept = false;
366
+ /** Queued + running start/update/stop calls (stc-05's cleaner reads it via activity()). */
367
+ opsInFlight = 0;
366
368
  constructor(opts) {
367
369
  this.opts = {
368
370
  previewsBaseDir: opts.previewsBaseDir,
@@ -377,6 +379,20 @@ class PreviewRunner {
377
379
  toolsDir: opts.toolsDir ?? path.join(opts.previewsBaseDir, '.tools'),
378
380
  };
379
381
  }
382
+ /**
383
+ * stc-05 — what storage cleanup must leave alone right now: any queued or
384
+ * running start/update/stop (`opInFlight` — it may be writing any slot),
385
+ * and every workspace with a preview building or serving.
386
+ */
387
+ activity() {
388
+ const activeWorkspaces = [];
389
+ for (const [ws, st] of this.states) {
390
+ const alive = !!(st.child && st.child.exitCode === null && st.child.signalCode === null);
391
+ if (alive || st.status === 'building')
392
+ activeWorkspaces.push(ws);
393
+ }
394
+ return { opInFlight: this.opsInFlight > 0, activeWorkspaces };
395
+ }
380
396
  /** Bring the preview up. Already serving this workspace in this mode → answered as-is. */
381
397
  start(req) {
382
398
  return this.serialize(async () => {
@@ -458,7 +474,8 @@ class PreviewRunner {
458
474
  }
459
475
  // ── internals ────────────────────────────────────────────────────────────
460
476
  serialize(fn) {
461
- const next = this.chain.then(fn, fn);
477
+ this.opsInFlight += 1;
478
+ const next = this.chain.then(fn, fn).finally(() => { this.opsInFlight -= 1; });
462
479
  this.chain = next.catch(() => undefined);
463
480
  return next;
464
481
  }
@@ -10,6 +10,7 @@ import { GitRun } from './changesReporter';
10
10
  import { buildChangesReport as buildChangesReportReal } from './changesReport';
11
11
  import { CheckoutInventoryDeps } from './checkoutInventory';
12
12
  import type { CheckoutResult } from './repoCheckout';
13
+ import { type AutomaticCleanupResult, type StorageCleanerDeps } from './storageCleanup';
13
14
  import type { PreviewRunner } from './previewCheckout';
14
15
  /**
15
16
  * fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
@@ -76,6 +77,8 @@ export declare function assembleSessionEnv(frame: {
76
77
  };
77
78
  env?: Record<string, string>;
78
79
  sessionId?: string;
80
+ /** a9be9720 — where git-credential-wenrwa asks this bridge for a fresh token. */
81
+ gitTokenSocket?: string;
79
82
  }): Record<string, string>;
80
83
  /** Minimal surface relayClient needs from a socket — lets tests inject fakes if ever needed. */
81
84
  export interface WebSocketLike {
@@ -102,6 +105,10 @@ export interface BackoffOpts {
102
105
  jitter?: () => number;
103
106
  }
104
107
  export interface RelayClientOpts {
108
+ /** a9be9720 — the unix socket this bridge serves fresh git credentials on
109
+ * (gitTokenBroker.ts). Put on every workspace PTY's env. Absent → the
110
+ * helper uses the token injected at open, as before. */
111
+ gitTokenSocket?: string;
105
112
  relayUrl: string;
106
113
  relayToken: string;
107
114
  sessions: SessionManager;
@@ -540,6 +547,18 @@ export interface RelayClientOpts {
540
547
  exists: (path: string) => boolean;
541
548
  run: CheckoutInventoryDeps['run'];
542
549
  };
550
+ /**
551
+ * stc-05 — opt IN to storage cleanup: report `cleanup-report` (what Clean
552
+ * up can free) on connect and on every `inventory-scan`, and honor
553
+ * `cleanup-run`. Absent → no report is ever sent (the backend reads its
554
+ * absence as "this bridge can't clean" and the arena hides the button) and
555
+ * `cleanup-run` answers `unsupported`. HOSTED LANE ONLY. Requires
556
+ * reposBaseDir + storageCleanupDeps.
557
+ */
558
+ enableStorageCleanup?: boolean;
559
+ /** storageCleanup.ts deps minus reposBaseDir/isAttached, which this module
560
+ * supplies (isAttached reads the same live `relayOwned` map reclaim uses). */
561
+ storageCleanupDeps?: Omit<StorageCleanerDeps, 'reposBaseDir' | 'isAttached'>;
543
562
  /**
544
563
  * Rock-solid sleep/wake — reads the container's memory (/proc/meminfo, see
545
564
  * containerMemory.ts) so a PTY spawn that cannot fit is REFUSED with a
@@ -550,6 +569,16 @@ export interface RelayClientOpts {
550
569
  * also proceeds — the same discipline the awake-side reconcile uses.
551
570
  */
552
571
  readMemInfo?: () => MemInfo | null;
572
+ /**
573
+ * stc-06 — free/total GB of the volume the checkouts live on, added to
574
+ * every `sessions` frame as `disk` (the backend's disk-pressure signal and
575
+ * capability flag). null/throw = unknown → the field is omitted. Absent →
576
+ * never reported (laptop lane, older embedders). Requires reportSessions.
577
+ */
578
+ readDiskSpace?: () => {
579
+ freeGb: number;
580
+ totalGb: number;
581
+ } | null;
553
582
  /** Per-agent memory estimate in MB for the check above. Default
554
583
  * DEFAULT_PER_AGENT_MB (measured, see containerMemory.ts). */
555
584
  perAgentMemoryMb?: number;
@@ -693,6 +722,24 @@ export interface RelayClientHandle {
693
722
  /** Stop reconnecting and close the current socket. Sessions are untouched — they live in SessionManager. */
694
723
  close(): void;
695
724
  isConnected(): boolean;
725
+ /**
726
+ * a9be9720 — ask the relay for a fresh git credential for one workspace.
727
+ * Rejects at once (code OFFLINE) with no relay connection, and with the
728
+ * relay's own code/sentence on a refusal; bounded, never hangs.
729
+ */
730
+ requestGitToken(workspaceId: string): Promise<{
731
+ token: string;
732
+ repoFullName: string;
733
+ expiresAt: string;
734
+ }>;
735
+ /**
736
+ * stc-05 → stc-06 — free caches + idle preview slots before a disk grows.
737
+ * Behind a kill switch (storageCleanupDeps.autoCleanupEnabled, default
738
+ * off): `{ ran: false, reason: 'disabled' }` until it is turned on. A
739
+ * bridge without cleanup answers the same way. Sends a fresh report after
740
+ * a run that freed anything.
741
+ */
742
+ runAutomaticCleanup(): Promise<AutomaticCleanupResult>;
696
743
  }
697
744
  /** twr-05 — snapshot handed to `onHealth`: enough for a `fly logs` reader to
698
745
  * tell a wedged bridge (reconnecting, attempt climbing, sessions frozen)
@@ -112,6 +112,9 @@ const diskPreflight_1 = require("./diskPreflight");
112
112
  // hcs-05's checkoutReclaim executor for the actual delete. See
113
113
  // checkoutParkAndRemove.ts's doc comment for the full safety design.
114
114
  const checkoutParkAndRemove_1 = require("./checkoutParkAndRemove");
115
+ // stc-05 — measure + delete only rebuildable data (caches, idle preview
116
+ // slots, removed-checkout leftovers, build output in clean repos).
117
+ const storageCleanup_1 = require("./storageCleanup");
115
118
  const attachmentWriter_1 = require("./attachmentWriter");
116
119
  const envSaver_1 = require("./envSaver");
117
120
  const envNames_1 = require("./envNames");
@@ -186,6 +189,10 @@ function assembleSessionEnv(frame) {
186
189
  env.WENRWA_GIT_TOKEN = frame.gitCredential.token;
187
190
  env.WENRWA_GIT_REPO = frame.gitCredential.repoFullName;
188
191
  }
192
+ // a9be9720 — the token above is an hour-lived GitHub token injected once;
193
+ // the helper asks the bridge here first and keeps it only as a fallback.
194
+ if (frame.gitTokenSocket)
195
+ env.WENRWA_GIT_TOKEN_SOCKET = frame.gitTokenSocket;
189
196
  // grad-06 — the commit identity. Nothing set one, so `git commit` failed in
190
197
  // every hosted terminal with "Author identity unknown": a container has no
191
198
  // domain in its hostname, so git refuses to auto-detect an address. Both
@@ -242,6 +249,8 @@ function describeDialError(err) {
242
249
  }
243
250
  return String(err);
244
251
  }
252
+ /** a9be9720 — how long a `git-token` request may go unanswered. `git` waits on it. */
253
+ const GIT_TOKEN_REQUEST_TIMEOUT_MS = 10_000;
245
254
  function send(ws, frame) {
246
255
  if (ws.readyState === ws_1.WebSocket.OPEN)
247
256
  ws.send(JSON.stringify(frame));
@@ -377,6 +386,48 @@ function startRelayClient(opts) {
377
386
  reclaimer,
378
387
  })
379
388
  : null;
389
+ // stc-05 — storage cleaner. ONE per process (its in-flight lock must span
390
+ // every `cleanup-run` across reconnects, like `reclaimer` above).
391
+ const storageCleaner = opts.enableStorageCleanup && opts.reposBaseDir && opts.storageCleanupDeps
392
+ ? (0, storageCleanup_1.createStorageCleaner)({
393
+ ...opts.storageCleanupDeps,
394
+ reposBaseDir: opts.reposBaseDir,
395
+ isAttached: (workspaceId) => [...relayOwned.values()].some((s) => s.workspaceId === workspaceId),
396
+ })
397
+ : null;
398
+ // Measuring walks node_modules — never block on it, never run two at once,
399
+ // and run once more if asked mid-measure (same shape as the inventory reporter).
400
+ let cleanupMeasuring = false;
401
+ let cleanupMeasureAgain = false;
402
+ const reportCleanup = () => {
403
+ if (!storageCleaner)
404
+ return;
405
+ if (cleanupMeasuring) {
406
+ cleanupMeasureAgain = true;
407
+ return;
408
+ }
409
+ cleanupMeasuring = true;
410
+ storageCleaner
411
+ .measure()
412
+ .then((r) => { if (ws && connected)
413
+ send(ws, { t: 'cleanup-report', cleanupVersion: storageCleanup_1.CLEANUP_VERSION, ...r }); })
414
+ .catch((err) => console.error('[bridge] cleanup measure failed:', err))
415
+ .finally(() => {
416
+ cleanupMeasuring = false;
417
+ if (cleanupMeasureAgain) {
418
+ cleanupMeasureAgain = false;
419
+ reportCleanup();
420
+ }
421
+ });
422
+ };
423
+ async function runAutomaticCleanup() {
424
+ if (!storageCleaner)
425
+ return { ran: false, reason: 'disabled' };
426
+ const r = await storageCleaner.runAutomaticCleanup();
427
+ if (r.ran && r.freedBytes > 0)
428
+ reportCleanup();
429
+ return r;
430
+ }
380
431
  // tchp-21 — session snapshot anchors (sessionAnchor.ts), keyed by
381
432
  // (relay) sessionId. Populated by maybeCreateSessionAnchor below once
382
433
  // createSessionAnchor resolves; read by currentChangesSessions() so a
@@ -446,6 +497,32 @@ function startRelayClient(opts) {
446
497
  let stopped = false;
447
498
  let ws = null;
448
499
  let connected = false;
500
+ // a9be9720 — outstanding git-token requests, by requestId.
501
+ const pendingGitTokens = new Map();
502
+ let gitTokenSeq = 0;
503
+ const gitTokenError = (code, message) => Object.assign(new Error(message), { code });
504
+ const failPendingGitTokens = (code, message) => {
505
+ for (const [id, p] of pendingGitTokens) {
506
+ clearTimeout(p.timer);
507
+ pendingGitTokens.delete(id);
508
+ p.reject(gitTokenError(code, message));
509
+ }
510
+ };
511
+ const requestGitToken = (workspaceId) => new Promise((resolve, reject) => {
512
+ const socket = ws;
513
+ if (!connected || !socket || socket.readyState !== ws_1.WebSocket.OPEN) {
514
+ reject(gitTokenError('OFFLINE', 'The bridge is not connected to HiveOp'));
515
+ return;
516
+ }
517
+ const requestId = `gt-${++gitTokenSeq}-${Date.now().toString(36)}`;
518
+ const timer = setTimeout(() => {
519
+ pendingGitTokens.delete(requestId);
520
+ reject(gitTokenError('TIMEOUT', 'HiveOp did not answer the git token request'));
521
+ }, GIT_TOKEN_REQUEST_TIMEOUT_MS);
522
+ timer.unref?.();
523
+ pendingGitTokens.set(requestId, { resolve, reject, timer });
524
+ send(socket, { t: 'git-token', requestId, workspaceId });
525
+ });
449
526
  let attempt = 0;
450
527
  let reconnectHandle = null;
451
528
  let healthHandle = null;
@@ -609,7 +686,19 @@ function startRelayClient(opts) {
609
686
  busy = undefined; // a failed sample must never drop the inventory
610
687
  }
611
688
  }
612
- send(ws, busy ? { t: 'sessions', ids, busy } : { t: 'sessions', ids });
689
+ let disk = null;
690
+ if (opts.readDiskSpace) {
691
+ try {
692
+ const d = opts.readDiskSpace();
693
+ if (d && Number.isFinite(d.freeGb) && Number.isFinite(d.totalGb) && d.totalGb > 0) {
694
+ disk = { freeGb: Math.round(d.freeGb * 100) / 100, totalGb: Math.round(d.totalGb * 100) / 100 };
695
+ }
696
+ }
697
+ catch {
698
+ disk = null; // unknown never blocks the inventory
699
+ }
700
+ }
701
+ send(ws, { t: 'sessions', ids, ...(busy ? { busy } : {}), ...(disk ? { disk } : {}) });
613
702
  }
614
703
  /**
615
704
  * tchp-02 — the ACTIVE sessions worth polling for working-tree changes:
@@ -944,6 +1033,8 @@ function startRelayClient(opts) {
944
1033
  // anything below it, and its own in-flight guard means a rapid
945
1034
  // reconnect never restarts a scan already running.
946
1035
  checkoutInventoryReporter?.trigger();
1036
+ // stc-05 — and what Clean up could free (absent on a bridge without it).
1037
+ reportCleanup();
947
1038
  });
948
1039
  socket.on('message', (raw) => {
949
1040
  markLinkAlive();
@@ -991,6 +1082,20 @@ function startRelayClient(opts) {
991
1082
  }
992
1083
  try {
993
1084
  switch (frame.t) {
1085
+ case 'git-token': {
1086
+ const p = pendingGitTokens.get(frame.requestId);
1087
+ if (!p)
1088
+ break;
1089
+ pendingGitTokens.delete(frame.requestId);
1090
+ clearTimeout(p.timer);
1091
+ if (frame.ok && frame.token && frame.repoFullName && frame.expiresAt) {
1092
+ p.resolve({ token: frame.token, repoFullName: frame.repoFullName, expiresAt: frame.expiresAt });
1093
+ }
1094
+ else {
1095
+ p.reject(gitTokenError(frame.code ?? 'GIT_TOKEN_FAILED', frame.message ?? 'HiveOp could not mint a git token'));
1096
+ }
1097
+ break;
1098
+ }
994
1099
  case 'open': {
995
1100
  // Capture into locals so the async .then closures below keep the
996
1101
  // discriminated-union narrowing TS would otherwise drop.
@@ -1172,7 +1277,7 @@ function startRelayClient(opts) {
1172
1277
  // shared container. autolaunch.sh reads HT_AUTONOMOUS from the
1173
1278
  // PTY's own env (claude vs claude --dangerously-skip-permissions),
1174
1279
  // NOT a machine-wide var.
1175
- const env = assembleSessionEnv({ workspaceId, autonomous, connectToken, gitCredential, gitIdentity, env: injectedEnv, sessionId: relaySessionId });
1280
+ const env = assembleSessionEnv({ workspaceId, autonomous, connectToken, gitCredential, gitIdentity, env: injectedEnv, sessionId: relaySessionId, gitTokenSocket: opts.gitTokenSocket });
1176
1281
  if (opts.resolveWorkspaceCwd) {
1177
1282
  opts
1178
1283
  .resolveWorkspaceCwd({ workspaceId, repoUrl, gitCredential })
@@ -1193,6 +1298,9 @@ function startRelayClient(opts) {
1193
1298
  ...(relaySessionId ? { sessionId: relaySessionId } : {}),
1194
1299
  code: err.code,
1195
1300
  message: err.message,
1301
+ // stc-04 — the growth price, untouched; the pane asks
1302
+ // Grow / Cancel and the arena makes the grow call.
1303
+ ...(err.quote ? { quote: err.quote } : {}),
1196
1304
  });
1197
1305
  return;
1198
1306
  }
@@ -1376,6 +1484,41 @@ function startRelayClient(opts) {
1376
1484
  }
1377
1485
  case 'inventory-scan': {
1378
1486
  checkoutInventoryReporter?.trigger();
1487
+ reportCleanup();
1488
+ break;
1489
+ }
1490
+ case 'cleanup-run': {
1491
+ if (!storageCleaner) {
1492
+ send(socket, { t: 'cleanup-result', outcome: 'unsupported' });
1493
+ break;
1494
+ }
1495
+ storageCleaner
1496
+ .clean()
1497
+ .then((r) => {
1498
+ if (r.busy)
1499
+ send(socket, { t: 'cleanup-result', outcome: 'busy' });
1500
+ else
1501
+ send(socket, { t: 'cleanup-result', outcome: 'done', freedBytes: r.freedBytes, byCategory: r.byCategory, skipped: r.skipped.length });
1502
+ reportCleanup();
1503
+ checkoutInventoryReporter?.trigger();
1504
+ })
1505
+ .catch((err) => {
1506
+ console.error('[bridge] cleanup failed:', err);
1507
+ send(socket, { t: 'cleanup-result', outcome: 'error', reason: err instanceof Error ? err.message : 'cleanup failed' });
1508
+ });
1509
+ break;
1510
+ }
1511
+ case 'cleanup-auto': {
1512
+ runAutomaticCleanup()
1513
+ .then((r) => {
1514
+ send(socket, r.ran ? { t: 'cleanup-auto-result', ran: true, freedBytes: r.freedBytes } : { t: 'cleanup-auto-result', ran: false, reason: r.reason });
1515
+ if (r.ran)
1516
+ checkoutInventoryReporter?.trigger();
1517
+ })
1518
+ .catch((err) => {
1519
+ console.error('[bridge] automatic cleanup failed:', err);
1520
+ send(socket, { t: 'cleanup-auto-result', ran: false, reason: 'error' });
1521
+ });
1379
1522
  break;
1380
1523
  }
1381
1524
  case 'park-save':
@@ -1532,6 +1675,7 @@ function startRelayClient(opts) {
1532
1675
  // past.
1533
1676
  ...(result.code ? { code: result.code } : {}),
1534
1677
  ...(result.message ? { message: result.message } : {}),
1678
+ ...(result.quote ? { quote: result.quote } : {}),
1535
1679
  });
1536
1680
  })
1537
1681
  .catch((err) => {
@@ -1888,6 +2032,7 @@ function startRelayClient(opts) {
1888
2032
  });
1889
2033
  const onDown = (info) => {
1890
2034
  connected = false;
2035
+ failPendingGitTokens('OFFLINE', 'The bridge lost its HiveOp connection');
1891
2036
  stopHeartbeat();
1892
2037
  // Detach (not close) every live handle: the sessions/PTYs survive the
1893
2038
  // drop in the SessionManager, scrollback keeps accumulating, and the
@@ -2086,8 +2231,11 @@ function startRelayClient(opts) {
2086
2231
  if (hookActivityHandle !== null)
2087
2232
  cancelInterval(hookActivityHandle);
2088
2233
  opts.companionForwarder?.stop(); // bws-08 — close every forwarded listener on bridge shutdown
2234
+ failPendingGitTokens('OFFLINE', 'The bridge is shutting down');
2089
2235
  ws?.close();
2090
2236
  },
2091
2237
  isConnected: () => connected,
2238
+ requestGitToken,
2239
+ runAutomaticCleanup,
2092
2240
  };
2093
2241
  }
@@ -1,4 +1,4 @@
1
- import { StorageQuotaFact } from './storageQuotaPreflight';
1
+ import { StorageQuotaFact, GrowQuote } from './storageQuotaPreflight';
2
2
  export interface RepoCheckoutDeps {
3
3
  baseDir: string;
4
4
  exists: (path: string) => boolean;
@@ -48,6 +48,8 @@ export interface CheckoutResult {
48
48
  code?: string;
49
49
  /** Set only when action === 'refused' — one complete, actionable sentence. */
50
50
  message?: string;
51
+ /** stc-04 — a quota refusal the backend priced a disk growth for. */
52
+ quote?: GrowQuote;
51
53
  }
52
54
  export declare function parseRepoFullName(repoUrl: string): string | null;
53
55
  export declare function ensureRepoCheckout(repoUrl: string, deps: RepoCheckoutDeps): Promise<CheckoutResult>;
@@ -195,7 +195,12 @@ async function ensureWorkspaceRepo(opts, deps) {
195
195
  if (verdict && verdict.kind === 'over-quota') {
196
196
  const message = (0, storageQuotaPreflight_1.storageQuotaPreflightMessage)(verdict);
197
197
  console.error(`[repoCheckout] refusing clone for workspace ${workspaceId} (${repoUrl}) — ${message}`);
198
- return { action: 'refused', code: storageQuotaPreflight_1.CLONE_QUOTA_PREFLIGHT_REFUSED, message };
198
+ return {
199
+ action: 'refused',
200
+ code: storageQuotaPreflight_1.CLONE_QUOTA_PREFLIGHT_REFUSED,
201
+ message,
202
+ ...(verdict.fact.quote ? { quote: verdict.fact.quote } : {}),
203
+ };
199
204
  }
200
205
  }
201
206
  try {
@@ -0,0 +1,66 @@
1
+ import { type InventoryRun, type ReadText } from './checkoutInventory';
2
+ export type CleanupCategory = 'caches' | 'previews' | 'removedCheckouts' | 'buildOutput';
3
+ export declare const CLEANUP_CATEGORIES: readonly CleanupCategory[];
4
+ /** Stamped on every report so the backend/arena can tell a bridge that can clean. */
5
+ export declare const CLEANUP_VERSION = 1;
6
+ /** Directory names that are build output by convention — deleted only when the repo ignores them. */
7
+ export declare const BUILD_OUTPUT_DIRS: ReadonlySet<string>;
8
+ /** What the preview runner is doing right now (PreviewRunner#activity). */
9
+ export interface PreviewActivity {
10
+ /** A start/update/stop is queued or running — which workspace it touches is not known. */
11
+ opInFlight: boolean;
12
+ /** Workspaces with a preview building or serving. */
13
+ activeWorkspaces: string[];
14
+ }
15
+ export interface StorageCleanerDeps {
16
+ /** e.g. /data/repos — one checkout per workspace id. */
17
+ reposBaseDir: string;
18
+ /** e.g. /data/previews. Absent → the previews category is empty. */
19
+ previewsBaseDir?: string;
20
+ /** Absolute cache directories (see defaultCacheDirs). Each is removed whole. */
21
+ cacheDirs: string[];
22
+ /** Is a session attached to this workspace RIGHT NOW? */
23
+ isAttached: (workspaceId: string) => boolean;
24
+ /** Absent → treated as "no preview running". */
25
+ previewActivity?: () => PreviewActivity;
26
+ /** git runner, `run('git', ['-C', path, ...])` — the same one the inventory uses. */
27
+ run: InventoryRun;
28
+ /** Bytes on disk under a path. Default: a block-based walk (what `du` reports). */
29
+ sizeBytes?: (p: string) => Promise<number>;
30
+ /** Recursive, forced removal. Default fs.promises.rm. */
31
+ rm?: (p: string) => Promise<void>;
32
+ readText?: ReadText;
33
+ /** The kill switch for runAutomaticCleanup. Default false. */
34
+ autoCleanupEnabled?: boolean;
35
+ }
36
+ export type CleanupBytes = Record<CleanupCategory, number>;
37
+ export interface CleanupReport {
38
+ reclaimableBytes: number;
39
+ byCategory: CleanupBytes;
40
+ }
41
+ export interface CleanupSkip {
42
+ category: CleanupCategory;
43
+ path: string;
44
+ reason: string;
45
+ }
46
+ export interface CleanupRun {
47
+ /** Another run was already going; nothing was done. */
48
+ busy: boolean;
49
+ freedBytes: number;
50
+ byCategory: CleanupBytes;
51
+ skipped: CleanupSkip[];
52
+ }
53
+ export type AutomaticCleanupResult = ({
54
+ ran: true;
55
+ } & CleanupRun) | {
56
+ ran: false;
57
+ reason: 'disabled' | 'busy';
58
+ };
59
+ export interface StorageCleaner {
60
+ measure(): Promise<CleanupReport>;
61
+ clean(categories?: readonly CleanupCategory[]): Promise<CleanupRun>;
62
+ runAutomaticCleanup(): Promise<AutomaticCleanupResult>;
63
+ }
64
+ /** npm / uv / pip caches under each home-like root, de-duplicated. */
65
+ export declare function defaultCacheDirs(roots: Array<string | undefined>): string[];
66
+ export declare function createStorageCleaner(deps: StorageCleanerDeps): StorageCleaner;