@bridge4dev/runner 0.11.0
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 +86 -0
- package/dist/adapters/claude.d.ts +19 -0
- package/dist/adapters/claude.js +631 -0
- package/dist/adapters/codex-home.d.ts +61 -0
- package/dist/adapters/codex-home.js +234 -0
- package/dist/adapters/codex-protocol.d.ts +59 -0
- package/dist/adapters/codex-protocol.js +204 -0
- package/dist/adapters/codex.d.ts +61 -0
- package/dist/adapters/codex.js +1406 -0
- package/dist/adapters/types.d.ts +183 -0
- package/dist/adapters/types.js +5 -0
- package/dist/async-queue.d.ts +11 -0
- package/dist/async-queue.js +50 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.js +149 -0
- package/dist/auth-relay.d.ts +57 -0
- package/dist/auth-relay.js +289 -0
- package/dist/config.d.ts +96 -0
- package/dist/config.js +73 -0
- package/dist/fsview.d.ts +20 -0
- package/dist/fsview.js +122 -0
- package/dist/git.d.ts +54 -0
- package/dist/git.js +168 -0
- package/dist/gitops.d.ts +136 -0
- package/dist/gitops.js +596 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +352 -0
- package/dist/journal.d.ts +118 -0
- package/dist/journal.js +300 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +19 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +33 -0
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +272 -0
- package/dist/protocol.d.ts +754 -0
- package/dist/protocol.js +154 -0
- package/dist/self-update.d.ts +75 -0
- package/dist/self-update.js +221 -0
- package/dist/status-file.d.ts +14 -0
- package/dist/status-file.js +29 -0
- package/dist/supervisor.d.ts +216 -0
- package/dist/supervisor.js +1648 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/ws-client.d.ts +30 -0
- package/dist/ws-client.js +171 -0
- package/package.json +52 -0
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// Wire protocol with the DevBridge API (mirror of
|
|
3
|
+
// packages/api/src/modules/dev-orchestration/dev-orchestration.schema.ts —
|
|
4
|
+
// the API side is the source of truth).
|
|
5
|
+
export const SessionDescriptorSchema = z.object({
|
|
6
|
+
id: z.string().uuid(),
|
|
7
|
+
kind: z.enum(['TICKET', 'CHAT']),
|
|
8
|
+
agent: z.enum(['CLAUDE', 'CODEX']),
|
|
9
|
+
status: z.enum([
|
|
10
|
+
'STARTING',
|
|
11
|
+
'RUNNING',
|
|
12
|
+
'WAITING_INPUT',
|
|
13
|
+
'WAITING_PERMISSION',
|
|
14
|
+
'REVIEW',
|
|
15
|
+
'DONE',
|
|
16
|
+
'FAILED',
|
|
17
|
+
'STOPPED',
|
|
18
|
+
]),
|
|
19
|
+
// Empty for free CHAT sessions: the agent boots and waits for the first message.
|
|
20
|
+
prompt: z.string(),
|
|
21
|
+
providerSessionId: z.string().nullable(),
|
|
22
|
+
// Interaction settings (session-5): normalized across agents.
|
|
23
|
+
mode: z.enum(['ask', 'plan', 'auto', 'full']).optional().default('ask'),
|
|
24
|
+
model: z.string().nullable().optional().default(null),
|
|
25
|
+
// Reasoning effort for agents that advertise levels (Codex Sol line adds
|
|
26
|
+
// max/ultra) — null when the agent has no such dial or none was picked.
|
|
27
|
+
effort: z.string().nullable().optional().default(null),
|
|
28
|
+
// Highest event seq the API already stored for this session — the journal
|
|
29
|
+
// never reuses it after a runner state wipe (QA-99 MAJOR-3).
|
|
30
|
+
lastSeq: z.number().int().min(0).optional().default(0),
|
|
31
|
+
// Cost already accumulated (base for a restarted runner). Optional for
|
|
32
|
+
// compatibility with older API versions.
|
|
33
|
+
costUsd: z.number().min(0).optional().default(0),
|
|
34
|
+
// Bumped by the API every time the session is resumed from a terminal state.
|
|
35
|
+
// The journal stamps it on status lines so an older life's terminal status
|
|
36
|
+
// cannot be replayed over a session that has since been picked back up.
|
|
37
|
+
epoch: z.number().int().min(0).optional().default(0),
|
|
38
|
+
// Agent-active milliseconds already spent (session 7). The time budget spends
|
|
39
|
+
// THIS, not wall clock — waiting on a human must not burn the limit.
|
|
40
|
+
activeMsBase: z.number().int().min(0).optional().default(0),
|
|
41
|
+
// Extra minutes granted by «Продолжить», on top of the workspace budget.
|
|
42
|
+
extraBudgetMinutes: z.number().int().min(0).nullable().optional().default(null),
|
|
43
|
+
workspace: z.object({
|
|
44
|
+
id: z.string().uuid(),
|
|
45
|
+
path: z.string(),
|
|
46
|
+
projectId: z.string().uuid(),
|
|
47
|
+
trustMode: z.enum(['STRICT', 'NORMAL', 'AUTO']),
|
|
48
|
+
budgetUsd: z.number().nullable(),
|
|
49
|
+
budgetMinutes: z.number().nullable(),
|
|
50
|
+
}),
|
|
51
|
+
tickets: z.array(z.object({ id: z.string().uuid(), number: z.number(), title: z.string() })),
|
|
52
|
+
// Branch the API would like this session to use (derived from its ticket
|
|
53
|
+
// group). Optional: an older API omits it and the runner keeps its
|
|
54
|
+
// id-derived name. Validated here because it becomes a git ref.
|
|
55
|
+
// A malformed hint must never cost us the descriptor: the frame carries the
|
|
56
|
+
// whole session (and hello_ack carries every session of the server), so a
|
|
57
|
+
// strict failure here used to drop them all (QA-100 MAJOR-1). Bad hint =>
|
|
58
|
+
// no hint, and the runner falls back to its id-derived branch name.
|
|
59
|
+
branchHint: z
|
|
60
|
+
.string()
|
|
61
|
+
.max(120)
|
|
62
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/)
|
|
63
|
+
.optional()
|
|
64
|
+
.catch(undefined),
|
|
65
|
+
// Auto-provisioned DevBridge MCP access (dbk_ key scoped to the workspace
|
|
66
|
+
// project). Preferred over the [mcp] section of config.toml when present.
|
|
67
|
+
mcp: z.object({ url: z.string().url(), token: z.string().min(1) }).optional(),
|
|
68
|
+
});
|
|
69
|
+
export const GatewayFrameSchema = z.discriminatedUnion('type', [
|
|
70
|
+
z.object({
|
|
71
|
+
type: z.literal('hello_ack'),
|
|
72
|
+
serverId: z.string(),
|
|
73
|
+
serverName: z.string(),
|
|
74
|
+
// How many sessions this server may run at once (session 8). Absent from an
|
|
75
|
+
// older API — the runner then keeps its historical single-slot behaviour.
|
|
76
|
+
// Deliberately NOT bounded to the runner's own hard cap here: an
|
|
77
|
+
// out-of-range number is clamped by the supervisor, while `undefined` means
|
|
78
|
+
// "this API never told us" and collapses the server to one agent. Rejecting
|
|
79
|
+
// 999 into `undefined` would confuse the two (QA-102 MINOR-5). `.catch`
|
|
80
|
+
// still covers genuinely malformed values, because a nonsense field must
|
|
81
|
+
// not cost us the frame that carries every session of the server.
|
|
82
|
+
maxSessions: z.number().int().positive().optional().catch(undefined),
|
|
83
|
+
sessions: z.array(SessionDescriptorSchema),
|
|
84
|
+
}),
|
|
85
|
+
z.object({ type: z.literal('event_ack'), sessionId: z.string().uuid(), seq: z.number().int() }),
|
|
86
|
+
z.object({
|
|
87
|
+
type: z.literal('event_nack'),
|
|
88
|
+
sessionId: z.string().uuid(),
|
|
89
|
+
seq: z.number().int(),
|
|
90
|
+
reason: z.string(),
|
|
91
|
+
}),
|
|
92
|
+
z.object({ type: z.literal('session_start'), session: SessionDescriptorSchema }),
|
|
93
|
+
z.object({
|
|
94
|
+
type: z.literal('session_message'),
|
|
95
|
+
sessionId: z.string().uuid(),
|
|
96
|
+
text: z.string(),
|
|
97
|
+
// Files the user attached (session 10) — metadata only; the bytes are
|
|
98
|
+
// fetched with the runner's own token. `.catch` so a malformed entry costs
|
|
99
|
+
// the attachments, never the message the user typed.
|
|
100
|
+
attachments: z
|
|
101
|
+
.array(z.object({
|
|
102
|
+
id: z.string().uuid(),
|
|
103
|
+
fileName: z.string().max(500),
|
|
104
|
+
mimeType: z.string().max(200),
|
|
105
|
+
fileSize: z.number().int().min(0),
|
|
106
|
+
}))
|
|
107
|
+
.max(10)
|
|
108
|
+
.optional()
|
|
109
|
+
.catch(undefined),
|
|
110
|
+
}),
|
|
111
|
+
z.object({
|
|
112
|
+
type: z.literal('permission_answer'),
|
|
113
|
+
sessionId: z.string().uuid(),
|
|
114
|
+
requestId: z.string(),
|
|
115
|
+
allow: z.boolean(),
|
|
116
|
+
note: z.string().optional(),
|
|
117
|
+
}),
|
|
118
|
+
z.object({ type: z.literal('session_stop'), sessionId: z.string().uuid() }),
|
|
119
|
+
z.object({ type: z.literal('session_interrupt'), sessionId: z.string().uuid() }),
|
|
120
|
+
// Server-wide settings changed mid-connection (session 8) — today just the
|
|
121
|
+
// parallel-session ceiling.
|
|
122
|
+
z.object({
|
|
123
|
+
type: z.literal('server_settings'),
|
|
124
|
+
maxSessions: z.number().int().positive().optional().catch(undefined),
|
|
125
|
+
}),
|
|
126
|
+
z.object({
|
|
127
|
+
type: z.literal('session_settings'),
|
|
128
|
+
sessionId: z.string().uuid(),
|
|
129
|
+
model: z.string().min(1).max(120).optional(),
|
|
130
|
+
mode: z.enum(['ask', 'plan', 'auto', 'full']).optional(),
|
|
131
|
+
// null = clear the pin and fall back to the model's own default.
|
|
132
|
+
effort: z.string().min(1).max(40).nullable().optional(),
|
|
133
|
+
}),
|
|
134
|
+
z.object({
|
|
135
|
+
type: z.literal('command'),
|
|
136
|
+
requestId: z.string(),
|
|
137
|
+
// Deliberately a free string and NOT an enum, so every future command is
|
|
138
|
+
// additive. An unrecognised frame is dropped whole by ws-client with no
|
|
139
|
+
// reply, which means a newer API calling a command an older runner does not
|
|
140
|
+
// know would just hang until the gateway timeout — with a string the frame
|
|
141
|
+
// parses, reaches `default:` and answers "Unknown command" immediately.
|
|
142
|
+
// The strict `RunnerCommandName` union on the API side stays the authority.
|
|
143
|
+
// Known names: validate_path, clean, purge_session, reset_workspace,
|
|
144
|
+
// git_status, git_diff, git_commit, apply_session, revert_apply, fs_view,
|
|
145
|
+
// auth_status, login_start, login_code, self_update.
|
|
146
|
+
name: z.string().min(1).max(64),
|
|
147
|
+
workspaceId: z.string().optional(),
|
|
148
|
+
sessionId: z.string().optional(),
|
|
149
|
+
args: z.record(z.unknown()).optional(),
|
|
150
|
+
}),
|
|
151
|
+
z.object({ type: z.literal('revoked'), reason: z.string() }),
|
|
152
|
+
z.object({ type: z.literal('error'), message: z.string() }),
|
|
153
|
+
]);
|
|
154
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Updating the runner from the dashboard (session 9, plan A1).
|
|
3
|
+
*
|
|
4
|
+
* This installs and runs code on someone else's machine, so the whole module is
|
|
5
|
+
* written around four refusals rather than around the happy path:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Only our own tarball.** The URL must live on the same origin the runner
|
|
8
|
+
* is paired with. The API is trusted — it already tells us what to run — but
|
|
9
|
+
* "trusted" and "may name any URL on the internet" are different things.
|
|
10
|
+
* 2. **Only a real installation.** Running from a source checkout means npm
|
|
11
|
+
* would install a *second* copy elsewhere and the update would silently do
|
|
12
|
+
* nothing; we say so instead.
|
|
13
|
+
* 3. **Only under a supervisor.** The last step of an update is exiting, and
|
|
14
|
+
* something has to bring the process back. Without systemd the runner would
|
|
15
|
+
* simply disappear.
|
|
16
|
+
* 4. **Only if the new build actually starts.** The freshly installed binary is
|
|
17
|
+
* executed (`--version`, which loads the whole module graph) BEFORE we agree
|
|
18
|
+
* to restart. If it does not come up, the previous version is reinstalled
|
|
19
|
+
* from a tarball packed a moment earlier and nothing restarts.
|
|
20
|
+
*/
|
|
21
|
+
export interface SelfUpdateOutcome {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
fromVersion: string;
|
|
24
|
+
toVersion?: string;
|
|
25
|
+
/** True only when the caller should now exit and let the supervisor restart. */
|
|
26
|
+
restart: boolean;
|
|
27
|
+
detail?: string;
|
|
28
|
+
/** Where the previous version was saved, for a manual `npm i -g <file>`. */
|
|
29
|
+
rollbackTarball?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SelfUpdateOptions {
|
|
32
|
+
/** Tarball to install, e.g. `https://api…/api/v1/dev-setup/runner.tgz`. */
|
|
33
|
+
tarballUrl: string;
|
|
34
|
+
/** The API this runner is paired with — the only origin we install from. */
|
|
35
|
+
apiUrl: string;
|
|
36
|
+
/** Test seam. */
|
|
37
|
+
exec?: (file: string, args: string[], options: {
|
|
38
|
+
timeout: number;
|
|
39
|
+
env?: NodeJS.ProcessEnv;
|
|
40
|
+
}) => Promise<{
|
|
41
|
+
stdout: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
}>;
|
|
44
|
+
/** Test seam: the installed package directory (defaults to autodetect). */
|
|
45
|
+
packageDir?: string | null;
|
|
46
|
+
/** Test seam: is this process supervised (defaults to autodetect). */
|
|
47
|
+
supervised?: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Directory of the installed runner package, or null when it is running from a
|
|
51
|
+
* source checkout.
|
|
52
|
+
*
|
|
53
|
+
* The `node_modules` segment is the signal: a global npm install lives in
|
|
54
|
+
* `<prefix>/lib/node_modules/@bridge4dev/runner`, while a monorepo checkout never
|
|
55
|
+
* has one above `packages/runner`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveInstalledPackageDir(entry?: string): string | null;
|
|
58
|
+
/**
|
|
59
|
+
* Is something going to restart us?
|
|
60
|
+
*
|
|
61
|
+
* `INVOCATION_ID` is set by systemd for every service process since v232 and is
|
|
62
|
+
* the cheapest honest answer. Anything else (a terminal, tmux, a Docker
|
|
63
|
+
* entrypoint without a restart policy) is treated as "no" — refusing an update
|
|
64
|
+
* that would work is recoverable, disappearing from the user's server is not.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isSupervisedProcess(env?: NodeJS.ProcessEnv): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Same origin as the API we are paired with, and it really is a tarball.
|
|
69
|
+
*
|
|
70
|
+
* `URL.origin` covers the protocol as well, so a paired-over-https runner can
|
|
71
|
+
* never be talked into fetching its own replacement over http.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isTrustedTarballUrl(tarballUrl: string, apiUrl: string): boolean;
|
|
74
|
+
export declare function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdateOutcome>;
|
|
75
|
+
//# sourceMappingURL=self-update.d.ts.map
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { log } from './log.js';
|
|
6
|
+
import { stateDir } from './paths.js';
|
|
7
|
+
import { RUNNER_VERSION } from './version.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const NPM_TIMEOUT_MS = 180_000;
|
|
10
|
+
const VERIFY_TIMEOUT_MS = 30_000;
|
|
11
|
+
/**
|
|
12
|
+
* Every name this package has shipped under. `@devbridge/runner` was the name
|
|
13
|
+
* used by the tarball installs that predate the npm publish, and those runners
|
|
14
|
+
* are the ones that will download and install the renamed package — so a build
|
|
15
|
+
* that only recognised the new name would fail to find itself mid-update and
|
|
16
|
+
* refuse to continue. Keep the old name here as long as any server might still
|
|
17
|
+
* be running it.
|
|
18
|
+
*/
|
|
19
|
+
const PACKAGE_NAMES = ['@bridge4dev/runner', '@devbridge/runner'];
|
|
20
|
+
/**
|
|
21
|
+
* Directory of the installed runner package, or null when it is running from a
|
|
22
|
+
* source checkout.
|
|
23
|
+
*
|
|
24
|
+
* The `node_modules` segment is the signal: a global npm install lives in
|
|
25
|
+
* `<prefix>/lib/node_modules/@bridge4dev/runner`, while a monorepo checkout never
|
|
26
|
+
* has one above `packages/runner`.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveInstalledPackageDir(entry = process.argv[1] ?? '') {
|
|
29
|
+
let dir;
|
|
30
|
+
try {
|
|
31
|
+
dir = path.dirname(fs.realpathSync(entry));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
for (let i = 0; i < 8; i++) {
|
|
37
|
+
const manifest = path.join(dir, 'package.json');
|
|
38
|
+
if (fs.existsSync(manifest)) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
41
|
+
if (typeof parsed.name === 'string' && PACKAGE_NAMES.includes(parsed.name)) {
|
|
42
|
+
return dir.split(path.sep).includes('node_modules') ? dir : null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const parent = path.dirname(dir);
|
|
50
|
+
if (parent === dir)
|
|
51
|
+
break;
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Is something going to restart us?
|
|
58
|
+
*
|
|
59
|
+
* `INVOCATION_ID` is set by systemd for every service process since v232 and is
|
|
60
|
+
* the cheapest honest answer. Anything else (a terminal, tmux, a Docker
|
|
61
|
+
* entrypoint without a restart policy) is treated as "no" — refusing an update
|
|
62
|
+
* that would work is recoverable, disappearing from the user's server is not.
|
|
63
|
+
*/
|
|
64
|
+
export function isSupervisedProcess(env = process.env) {
|
|
65
|
+
return typeof env['INVOCATION_ID'] === 'string' && env['INVOCATION_ID'].length > 0;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Same origin as the API we are paired with, and it really is a tarball.
|
|
69
|
+
*
|
|
70
|
+
* `URL.origin` covers the protocol as well, so a paired-over-https runner can
|
|
71
|
+
* never be talked into fetching its own replacement over http.
|
|
72
|
+
*/
|
|
73
|
+
export function isTrustedTarballUrl(tarballUrl, apiUrl) {
|
|
74
|
+
let target;
|
|
75
|
+
let api;
|
|
76
|
+
try {
|
|
77
|
+
target = new URL(tarballUrl);
|
|
78
|
+
api = new URL(apiUrl);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return target.origin === api.origin && target.pathname.endsWith('.tgz');
|
|
84
|
+
}
|
|
85
|
+
function readVersion(packageDir) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
|
|
88
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** The CLI entry point of an installed package, used to smoke-test the update. */
|
|
95
|
+
function binPath(packageDir) {
|
|
96
|
+
return path.join(packageDir, 'dist', 'index.js');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* npm needs PATH/HOME and a writable cache; everything else is stripped, both to
|
|
100
|
+
* keep provider credentials out of a child process that touches the network and
|
|
101
|
+
* to stop a stray `npm_config_*` from redirecting the install.
|
|
102
|
+
*/
|
|
103
|
+
function npmEnv() {
|
|
104
|
+
const env = {
|
|
105
|
+
PATH: process.env['PATH'] ?? '/usr/local/bin:/usr/bin:/bin',
|
|
106
|
+
HOME: process.env['HOME'] ?? '',
|
|
107
|
+
// npm writes progress to a TTY-shaped stream otherwise.
|
|
108
|
+
npm_config_progress: 'false',
|
|
109
|
+
npm_config_fund: 'false',
|
|
110
|
+
npm_config_audit: 'false',
|
|
111
|
+
};
|
|
112
|
+
if (process.env['XDG_CACHE_HOME'])
|
|
113
|
+
env['XDG_CACHE_HOME'] = process.env['XDG_CACHE_HOME'];
|
|
114
|
+
return env;
|
|
115
|
+
}
|
|
116
|
+
export async function selfUpdate(options) {
|
|
117
|
+
const exec = options.exec ??
|
|
118
|
+
((file, args, opts) => execFileAsync(file, args, { timeout: opts.timeout, env: opts.env, maxBuffer: 4_000_000 }));
|
|
119
|
+
const fromVersion = RUNNER_VERSION;
|
|
120
|
+
const fail = (detail, extra = {}) => ({
|
|
121
|
+
ok: false,
|
|
122
|
+
fromVersion,
|
|
123
|
+
restart: false,
|
|
124
|
+
detail,
|
|
125
|
+
...extra,
|
|
126
|
+
});
|
|
127
|
+
if (!isTrustedTarballUrl(options.tarballUrl, options.apiUrl)) {
|
|
128
|
+
return fail('The update package must be served by the DevBridge API this runner is paired with');
|
|
129
|
+
}
|
|
130
|
+
const supervised = options.supervised ?? isSupervisedProcess();
|
|
131
|
+
if (!supervised) {
|
|
132
|
+
return fail('The runner is not running as a service, so nothing would start it again after the update. ' +
|
|
133
|
+
'Install it with `devbridge-runner install-service`, or update it by hand.');
|
|
134
|
+
}
|
|
135
|
+
const packageDir = options.packageDir === undefined ? resolveInstalledPackageDir() : options.packageDir;
|
|
136
|
+
if (!packageDir) {
|
|
137
|
+
return fail('This runner runs from a source checkout, not from an installed package — update it with git instead.');
|
|
138
|
+
}
|
|
139
|
+
// Pack the current version FIRST: without a rollback artefact there is no
|
|
140
|
+
// honest way back if the new build turns out to be broken.
|
|
141
|
+
const rollbackDir = path.join(stateDir(), 'rollback');
|
|
142
|
+
let rollbackTarball;
|
|
143
|
+
try {
|
|
144
|
+
fs.mkdirSync(rollbackDir, { recursive: true, mode: 0o700 });
|
|
145
|
+
const packed = await exec('npm', ['pack', packageDir, '--pack-destination', rollbackDir], {
|
|
146
|
+
timeout: NPM_TIMEOUT_MS,
|
|
147
|
+
env: npmEnv(),
|
|
148
|
+
});
|
|
149
|
+
const name = packed.stdout.trim().split('\n').pop()?.trim();
|
|
150
|
+
if (name) {
|
|
151
|
+
const candidate = path.join(rollbackDir, path.basename(name));
|
|
152
|
+
if (fs.existsSync(candidate))
|
|
153
|
+
rollbackTarball = candidate;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
log.warn('self-update: could not pack a rollback copy', { error: String(error) });
|
|
158
|
+
}
|
|
159
|
+
if (!rollbackTarball) {
|
|
160
|
+
return fail('Could not prepare a rollback copy of the current version — update aborted');
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
// `--ignore-scripts` matches the documented install: this package and its
|
|
164
|
+
// whole tree have no install/postinstall scripts, so nothing legitimate is
|
|
165
|
+
// skipped — and an update pulled over the network gets no chance to run
|
|
166
|
+
// anything at install time.
|
|
167
|
+
await exec('npm', ['install', '-g', '--ignore-scripts', options.tarballUrl], {
|
|
168
|
+
timeout: NPM_TIMEOUT_MS,
|
|
169
|
+
env: npmEnv(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
return fail(`Install failed: ${describe(error)}`, { rollbackTarball });
|
|
174
|
+
}
|
|
175
|
+
const toVersion = readVersion(packageDir) ?? undefined;
|
|
176
|
+
// The real test: does the newly installed build start? `--version` loads the
|
|
177
|
+
// whole module graph, so a half-downloaded package or a missing dependency
|
|
178
|
+
// fails here rather than after the restart, when nobody could see it.
|
|
179
|
+
try {
|
|
180
|
+
const probe = await exec(process.execPath, [binPath(packageDir), '--version'], {
|
|
181
|
+
timeout: VERIFY_TIMEOUT_MS,
|
|
182
|
+
env: npmEnv(),
|
|
183
|
+
});
|
|
184
|
+
const printed = probe.stdout.trim();
|
|
185
|
+
if (!/^\d+\.\d+\.\d+/.test(printed)) {
|
|
186
|
+
throw new Error(`unexpected version output: ${printed.slice(0, 120)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const detail = describe(error);
|
|
191
|
+
log.error('self-update: the new build did not start — rolling back', { error: detail });
|
|
192
|
+
try {
|
|
193
|
+
await exec('npm', ['install', '-g', '--ignore-scripts', rollbackTarball], {
|
|
194
|
+
timeout: NPM_TIMEOUT_MS,
|
|
195
|
+
env: npmEnv(),
|
|
196
|
+
});
|
|
197
|
+
return fail(`The new version did not start (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
198
|
+
}
|
|
199
|
+
catch (rollbackError) {
|
|
200
|
+
return fail(`The new version did not start (${detail}) and the rollback failed too (${describe(rollbackError)}). ` +
|
|
201
|
+
`Restore it on the server with: npm install -g ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
log.info('self-update: installed', { fromVersion, toVersion });
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
fromVersion,
|
|
208
|
+
...(toVersion ? { toVersion } : {}),
|
|
209
|
+
restart: true,
|
|
210
|
+
rollbackTarball,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function describe(error) {
|
|
214
|
+
if (error instanceof Error) {
|
|
215
|
+
const withStderr = error;
|
|
216
|
+
const stderr = typeof withStderr.stderr === 'string' ? withStderr.stderr.trim() : '';
|
|
217
|
+
return (stderr || error.message).slice(0, 400);
|
|
218
|
+
}
|
|
219
|
+
return String(error).slice(0, 400);
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=self-update.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface DaemonStatus {
|
|
2
|
+
pid: number;
|
|
3
|
+
connected: boolean;
|
|
4
|
+
serverId: string;
|
|
5
|
+
serverName: string;
|
|
6
|
+
apiUrl: string;
|
|
7
|
+
activeSessionIds: string[];
|
|
8
|
+
updatedAt: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const STATUS_FRESH_MS = 90000;
|
|
11
|
+
export declare function writeStatusFile(status: DaemonStatus): void;
|
|
12
|
+
export declare function readStatusFile(): DaemonStatus | null;
|
|
13
|
+
export declare function isPidAlive(pid: number): boolean;
|
|
14
|
+
//# sourceMappingURL=status-file.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { stateDir, statusFilePath } from './paths.js';
|
|
4
|
+
export const STATUS_FRESH_MS = 90_000;
|
|
5
|
+
export function writeStatusFile(status) {
|
|
6
|
+
const dir = stateDir();
|
|
7
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
8
|
+
const tmp = path.join(dir, `.status.json.${process.pid}.tmp`);
|
|
9
|
+
fs.writeFileSync(tmp, JSON.stringify(status, null, 2), { mode: 0o600 });
|
|
10
|
+
fs.renameSync(tmp, statusFilePath());
|
|
11
|
+
}
|
|
12
|
+
export function readStatusFile() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(statusFilePath(), 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function isPidAlive(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=status-file.js.map
|