@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.
- package/dist/__tests__/bridge-cleanup-frame.test.d.ts +1 -0
- package/dist/__tests__/bridge-cleanup-frame.test.js +217 -0
- package/dist/__tests__/bridge-clone-grow-quote.test.d.ts +1 -0
- package/dist/__tests__/bridge-clone-grow-quote.test.js +145 -0
- package/dist/__tests__/bridge-disk-pressure-frame.test.d.ts +1 -0
- package/dist/__tests__/bridge-disk-pressure-frame.test.js +179 -0
- package/dist/__tests__/bridge-preview-checkout.test.js +9 -0
- package/dist/__tests__/bridge-storage-cleanup-real-fs.test.d.ts +1 -0
- package/dist/__tests__/bridge-storage-cleanup-real-fs.test.js +372 -0
- package/dist/__tests__/git-token-refresh.test.d.ts +1 -0
- package/dist/__tests__/git-token-refresh.test.js +231 -0
- package/dist/bridge/diskPreflight.d.ts +4 -1
- package/dist/bridge/diskPreflight.js +5 -3
- package/dist/bridge/gitTokenBroker.d.ts +28 -0
- package/dist/bridge/gitTokenBroker.js +124 -0
- package/dist/bridge/previewCheckout.d.ts +11 -0
- package/dist/bridge/previewCheckout.js +18 -1
- package/dist/bridge/relayClient.d.ts +47 -0
- package/dist/bridge/relayClient.js +150 -2
- package/dist/bridge/repoCheckout.d.ts +3 -1
- package/dist/bridge/repoCheckout.js +6 -1
- package/dist/bridge/storageCleanup.d.ts +66 -0
- package/dist/bridge/storageCleanup.js +337 -0
- package/dist/bridge/storageQuotaFetch.js +12 -0
- package/dist/bridge/storageQuotaPreflight.d.ts +12 -0
- package/dist/commands/bridge.d.ts +10 -0
- package/dist/commands/bridge.js +59 -3
- package/dist/index.js +0 -0
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|