@ctrl-spc/cli 1.3.4 → 1.3.5
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/commands/init.js +4 -0
- package/dist/commands/login.js +4 -0
- package/dist/commands/logout.js +33 -4
- package/dist/commands/run.js +14 -2
- package/dist/index.js +23 -1
- package/dist/runtime-control.js +11 -5
- package/dist/wake.js +237 -0
- package/package.json +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -9,6 +9,7 @@ import { ask, select } from '../prompt.js';
|
|
|
9
9
|
import { buildTopologyTargets, componentTargetId, discoverRepositoryTopology, planExplicitGrouping, readGroupingManifest, repositoryTargetId, } from '../topology.js';
|
|
10
10
|
import { upsertProjectLink } from './link.js';
|
|
11
11
|
import { registerMachineWorkspace } from './run.js';
|
|
12
|
+
import { installWakeAgent } from '../wake.js';
|
|
12
13
|
function setGrouping(flags, grouping) {
|
|
13
14
|
if (flags.grouping) {
|
|
14
15
|
throw new Error(`Choose exactly one grouping option: --combine, --split, or --manifest <file>.`);
|
|
@@ -448,6 +449,9 @@ export async function init(argv = [], deps = {}) {
|
|
|
448
449
|
return;
|
|
449
450
|
}
|
|
450
451
|
const client = await getClient();
|
|
452
|
+
if (installWakeAgent()) {
|
|
453
|
+
console.log('Remote start enabled — this computer checks in every 60s so you can start it from the web. Disable: ctrl-spc wake off');
|
|
454
|
+
}
|
|
451
455
|
const { data: userData, error: userError } = await client.auth.getUser();
|
|
452
456
|
if (userError || !userData.user) {
|
|
453
457
|
console.error(`Could not resolve the signed-in user: ${userError?.message ?? 'no user returned'}`);
|
package/dist/commands/login.js
CHANGED
|
@@ -4,6 +4,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
4
4
|
import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
|
|
5
5
|
import { writeSession } from '../config.js';
|
|
6
6
|
import { getClient } from '../supabase.js';
|
|
7
|
+
import { installWakeAgent } from '../wake.js';
|
|
7
8
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
9
|
/** POST /callback bodies larger than this are rejected before JSON parsing. */
|
|
9
10
|
const MAX_CALLBACK_BODY_BYTES = 64 * 1024;
|
|
@@ -16,6 +17,9 @@ export async function login() {
|
|
|
16
17
|
const outcome = await runLoginServer();
|
|
17
18
|
if (outcome.ok) {
|
|
18
19
|
console.log(`Signed in as ${outcome.email}`);
|
|
20
|
+
if (installWakeAgent()) {
|
|
21
|
+
console.log('Remote start enabled — this computer checks in every 60s so you can start it from the web. Disable: ctrl-spc wake off');
|
|
22
|
+
}
|
|
19
23
|
process.exitCode = 0;
|
|
20
24
|
}
|
|
21
25
|
else {
|
package/dist/commands/logout.js
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
-
import { clearSession } from '../config.js';
|
|
1
|
+
import { clearSession, getMachineIdentity } from '../config.js';
|
|
2
|
+
import { getClient } from '../supabase.js';
|
|
3
|
+
import { removeWakeAgent } from '../wake.js';
|
|
4
|
+
async function defaultNullHeartbeats() {
|
|
5
|
+
const client = await getClient();
|
|
6
|
+
const { error } = await client
|
|
7
|
+
.from('machines')
|
|
8
|
+
.update({ waker_seen_at: null, broker_seen_at: null })
|
|
9
|
+
.eq('id', getMachineIdentity().id);
|
|
10
|
+
if (error)
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
2
13
|
/**
|
|
3
14
|
* `ctrl-spc logout`: deletes the stored session. Local-only — the refresh
|
|
4
15
|
* token is discarded, not revoked server-side (Supabase revokes it on next
|
|
5
16
|
* rotation attempt; fine for v1's single-session CLI).
|
|
6
17
|
*/
|
|
7
|
-
export function logout() {
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
export async function logout(deps = {}) {
|
|
19
|
+
try {
|
|
20
|
+
await (deps.nullHeartbeats ?? defaultNullHeartbeats)();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Offline or already signed out. The freshness window removes stale UI.
|
|
24
|
+
}
|
|
25
|
+
let wakeRemovalFailed = false;
|
|
26
|
+
try {
|
|
27
|
+
;
|
|
28
|
+
(deps.removeWake ?? removeWakeAgent)();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Clearing the authenticated session is the security boundary. A stale
|
|
32
|
+
// plist without a session can only wake, fail authentication, and exit.
|
|
33
|
+
wakeRemovalFailed = true;
|
|
34
|
+
}
|
|
35
|
+
if ((deps.clear ?? clearSession)()) {
|
|
36
|
+
console.log(wakeRemovalFailed
|
|
37
|
+
? 'Logged out — stored session removed. The background start check could not be removed, but it no longer has access to your account.'
|
|
38
|
+
: 'Logged out — stored session removed and remote start disabled.');
|
|
10
39
|
}
|
|
11
40
|
else {
|
|
12
41
|
console.log('No stored session — already logged out.');
|
package/dist/commands/run.js
CHANGED
|
@@ -13,6 +13,7 @@ import { startMcpServer } from '../mcp.js';
|
|
|
13
13
|
import { getMachineIdentity } from '../config.js';
|
|
14
14
|
import { discoverRepositoryTopology } from '../topology.js';
|
|
15
15
|
import { createAgentDispatchController } from '../dispatch.js';
|
|
16
|
+
import { installWakeAgent, stampMachineHeartbeat } from '../wake.js';
|
|
16
17
|
function mcpUrl() {
|
|
17
18
|
return `http://localhost:${MCP_PORT}/mcp`;
|
|
18
19
|
}
|
|
@@ -758,12 +759,13 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
|
|
|
758
759
|
* tool call can flip presence back to "working" mid-shutdown) and only then
|
|
759
760
|
* untracks presence, before exiting.
|
|
760
761
|
*/
|
|
761
|
-
function installShutdownHandler(presence, mcpServer, dispatch) {
|
|
762
|
+
function installShutdownHandler(presence, mcpServer, dispatch, onShutdown) {
|
|
762
763
|
let shuttingDown = false;
|
|
763
764
|
const shutdown = () => {
|
|
764
765
|
if (shuttingDown)
|
|
765
766
|
return;
|
|
766
767
|
shuttingDown = true;
|
|
768
|
+
onShutdown?.();
|
|
767
769
|
dispatch?.stop();
|
|
768
770
|
console.log('\nShutting down — closing MCP server, untracking presence...');
|
|
769
771
|
mcpServer
|
|
@@ -785,6 +787,8 @@ export async function run() {
|
|
|
785
787
|
if (retireLegacyDaemon()) {
|
|
786
788
|
console.log('Removed obsolete CTRL+SPC background launcher.');
|
|
787
789
|
}
|
|
790
|
+
if (process.argv[2] !== '__broker')
|
|
791
|
+
installWakeAgent();
|
|
788
792
|
const cwd = process.cwd();
|
|
789
793
|
const client = await getClient();
|
|
790
794
|
const { data: userData, error: userError } = await client.auth.getUser();
|
|
@@ -865,6 +869,14 @@ export async function run() {
|
|
|
865
869
|
await presence.stop().catch(() => { });
|
|
866
870
|
throw formatMcpStartupError(err, MCP_PORT);
|
|
867
871
|
}
|
|
872
|
+
// A broker heartbeat means the local server is genuinely accepting work.
|
|
873
|
+
// Stamp only after startMcpServer succeeds; otherwise a port/startup failure
|
|
874
|
+
// would make the web app show this computer as online for three minutes.
|
|
875
|
+
await stampMachineHeartbeat(client, machine.id, true).catch(() => { });
|
|
876
|
+
const heartbeatTimer = setInterval(() => {
|
|
877
|
+
void stampMachineHeartbeat(client, machine.id, true).catch(() => { });
|
|
878
|
+
}, 60_000);
|
|
879
|
+
heartbeatTimer.unref();
|
|
868
880
|
console.log(`MCP: ${url} (tools: list_tasks, get_task, begin_work, heartbeat_work, reserve_work_paths, ` +
|
|
869
881
|
'release_work_paths, transfer_coordination, acknowledge_unavailable_checkout, record_context_exploration, ' +
|
|
870
882
|
'set_task_role_slugs, ' +
|
|
@@ -873,5 +885,5 @@ export async function run() {
|
|
|
873
885
|
console.log(`Projects: ${projects.length > 0 ? projects.map((project) => project.name).join(', ') : 'none initialized on this machine'}`);
|
|
874
886
|
console.log('Presence: available');
|
|
875
887
|
const dispatch = createAgentDispatchController(client, machine.id, registeredAgents);
|
|
876
|
-
installShutdownHandler(presence, mcpServer, dispatch);
|
|
888
|
+
installShutdownHandler(presence, mcpServer, dispatch, () => clearInterval(heartbeatTimer));
|
|
877
889
|
}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { link } from './commands/link.js';
|
|
|
6
6
|
import { fetchRepository } from './commands/fetch.js';
|
|
7
7
|
import { run } from './commands/run.js';
|
|
8
8
|
import { openCompanion, serveCompanion } from './companion.js';
|
|
9
|
+
import { installWakeAgent, removeWakeAgent, wakeCheck } from './wake.js';
|
|
9
10
|
export const HELP_TEXT = `ctrl-spc — CTRL+SPC CLI
|
|
10
11
|
|
|
11
12
|
Usage:
|
|
@@ -18,6 +19,8 @@ Usage:
|
|
|
18
19
|
ctrl-spc fetch Clone and link one unavailable project repository
|
|
19
20
|
[repository-id] [--project <project-id>] [--destination <path>] [--yes]
|
|
20
21
|
ctrl-spc open Open the local connection manager
|
|
22
|
+
ctrl-spc wake Enable/disable starting this computer's agents from the web
|
|
23
|
+
on|off
|
|
21
24
|
ctrl-spc Run the local agent process (presence + MCP server)
|
|
22
25
|
ctrl-spc --help Show this help text
|
|
23
26
|
`;
|
|
@@ -28,7 +31,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
28
31
|
await login();
|
|
29
32
|
return;
|
|
30
33
|
case 'logout':
|
|
31
|
-
logout();
|
|
34
|
+
await logout();
|
|
32
35
|
return;
|
|
33
36
|
case 'init':
|
|
34
37
|
await init(argv.slice(1));
|
|
@@ -42,6 +45,25 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
42
45
|
case 'open':
|
|
43
46
|
await openCompanion();
|
|
44
47
|
return;
|
|
48
|
+
case 'wake':
|
|
49
|
+
if (argv[1] === 'off') {
|
|
50
|
+
console.log(removeWakeAgent()
|
|
51
|
+
? 'Remote start disabled on this computer.'
|
|
52
|
+
: 'Remote start was not enabled.');
|
|
53
|
+
}
|
|
54
|
+
else if (argv[1] === 'on') {
|
|
55
|
+
console.log(installWakeAgent()
|
|
56
|
+
? 'Remote start enabled — this computer checks in every 60s. Disable: ctrl-spc wake off'
|
|
57
|
+
: 'Remote start requires macOS.');
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.error('Usage: ctrl-spc wake on|off');
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
case '__wake-check':
|
|
65
|
+
await wakeCheck();
|
|
66
|
+
return;
|
|
45
67
|
case '__broker':
|
|
46
68
|
await run();
|
|
47
69
|
return;
|
package/dist/runtime-control.js
CHANGED
|
@@ -2,7 +2,10 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { configDir } from './config.js';
|
|
5
|
+
import { configDir, getMachineIdentity } from './config.js';
|
|
6
|
+
export function brokerMatchesRuntime(health, kind, machineId) {
|
|
7
|
+
return health?.runtime?.kind === kind && health.machine_id === machineId;
|
|
8
|
+
}
|
|
6
9
|
const CURRENT_PACKAGE_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
7
10
|
const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
|
|
8
11
|
export const LOCAL_RUNTIME_PORT = Number(process.env.CTRL_SPC_LOCAL_PORT) || 4572;
|
|
@@ -209,8 +212,11 @@ export async function startRuntime(kind) {
|
|
|
209
212
|
'Stop it from its terminal before switching connections.');
|
|
210
213
|
}
|
|
211
214
|
const currentHealth = await probeBroker(profile.port);
|
|
212
|
-
if (currentHealth?.runtime?.kind === kind)
|
|
213
|
-
|
|
215
|
+
if (currentHealth?.runtime?.kind === kind) {
|
|
216
|
+
if (brokerMatchesRuntime(currentHealth, kind, getMachineIdentity().id))
|
|
217
|
+
return;
|
|
218
|
+
throw new Error('Another CTRL+SPC connection is already running for a different computer identity. Stop it and try again.');
|
|
219
|
+
}
|
|
214
220
|
if (currentHealth) {
|
|
215
221
|
throw new Error(`Port ${profile.port} is already used by an unlabeled process. Turn it off before continuing.`);
|
|
216
222
|
}
|
|
@@ -242,10 +248,10 @@ export async function startRuntime(kind) {
|
|
|
242
248
|
if (childExited)
|
|
243
249
|
return true;
|
|
244
250
|
const health = await probeBroker(profile.port);
|
|
245
|
-
return health
|
|
251
|
+
return brokerMatchesRuntime(health, kind, getMachineIdentity().id);
|
|
246
252
|
}, 30_000, 250);
|
|
247
253
|
const health = await probeBroker(profile.port);
|
|
248
|
-
if (!ready || health
|
|
254
|
+
if (!ready || !brokerMatchesRuntime(health, kind, getMachineIdentity().id)) {
|
|
249
255
|
rmSync(pidPath(kind), { force: true });
|
|
250
256
|
const log = existsSync(logPath(kind)) ? readFileSync(logPath(kind), 'utf8').trim().split('\n').slice(-4).join('\n') : '';
|
|
251
257
|
throw new Error(`${profile.label} did not become ready.${log ? `\n${log}` : ''}`);
|
package/dist/wake.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { getClient } from './supabase.js';
|
|
7
|
+
import { getMachineIdentity } from './config.js';
|
|
8
|
+
import { currentCompanionKind, runtimeStatuses, startRuntime, } from './runtime-control.js';
|
|
9
|
+
const WAKE_LABEL = 'com.ctrl-spc.wake';
|
|
10
|
+
const WAKE_INTERVAL_SECONDS = 60;
|
|
11
|
+
const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
|
|
12
|
+
export const WAKE_REQUEST_FRESH_SECONDS = 150;
|
|
13
|
+
const FAILED_CLEANUP_MINUTES = 10;
|
|
14
|
+
export function wakeAgentPlistPath() {
|
|
15
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${WAKE_LABEL}.plist`);
|
|
16
|
+
}
|
|
17
|
+
function xmlEscape(value) {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, ''');
|
|
24
|
+
}
|
|
25
|
+
export function renderWakePlist(nodePath, entryPath, pathEnv) {
|
|
26
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
|
+
<plist version="1.0">
|
|
29
|
+
<dict>
|
|
30
|
+
<key>Label</key><string>${WAKE_LABEL}</string>
|
|
31
|
+
<key>ProgramArguments</key>
|
|
32
|
+
<array>
|
|
33
|
+
<string>${xmlEscape(nodePath)}</string>
|
|
34
|
+
<string>${xmlEscape(entryPath)}</string>
|
|
35
|
+
<string>__wake-check</string>
|
|
36
|
+
</array>
|
|
37
|
+
<key>StartInterval</key><integer>${WAKE_INTERVAL_SECONDS}</integer>
|
|
38
|
+
<key>RunAtLoad</key><true/>
|
|
39
|
+
<key>ProcessType</key><string>Background</string>
|
|
40
|
+
<key>EnvironmentVariables</key>
|
|
41
|
+
<dict>
|
|
42
|
+
<key>PATH</key><string>${xmlEscape(pathEnv)}</string>
|
|
43
|
+
</dict>
|
|
44
|
+
</dict>
|
|
45
|
+
</plist>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
function defaultLaunchctl(args) {
|
|
49
|
+
execFileSync('launchctl', args, { stdio: 'ignore' });
|
|
50
|
+
}
|
|
51
|
+
function defaultWrite(path, contents) {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
writeFileSync(path, contents, { mode: 0o644 });
|
|
54
|
+
chmodSync(path, 0o644);
|
|
55
|
+
}
|
|
56
|
+
function defaultRead(path) {
|
|
57
|
+
try {
|
|
58
|
+
return readFileSync(path, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function installWakeAgent(deps = {}) {
|
|
65
|
+
if (process.env.CTRL_SPC_NO_WAKE_AGENT === '1')
|
|
66
|
+
return false;
|
|
67
|
+
if (process.platform !== 'darwin' && !deps.plistPath)
|
|
68
|
+
return false;
|
|
69
|
+
const plistPath = deps.plistPath ?? wakeAgentPlistPath();
|
|
70
|
+
const contents = renderWakePlist(deps.nodePath ?? process.execPath, deps.entryPath ?? CURRENT_ENTRY_PATH, deps.pathEnv ?? process.env.PATH ?? '/usr/bin:/bin');
|
|
71
|
+
if ((deps.read ?? defaultRead)(plistPath) === contents)
|
|
72
|
+
return true;
|
|
73
|
+
(deps.write ?? defaultWrite)(plistPath, contents);
|
|
74
|
+
const uid = deps.uid ?? process.getuid?.();
|
|
75
|
+
if (uid === undefined)
|
|
76
|
+
return true;
|
|
77
|
+
const launchctl = deps.launchctl ?? defaultLaunchctl;
|
|
78
|
+
try {
|
|
79
|
+
launchctl(['bootout', `gui/${uid}`, plistPath]);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// The job was not loaded yet.
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
launchctl(['bootstrap', `gui/${uid}`, plistPath]);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// Do not leave a byte-identical plist behind after a failed bootstrap:
|
|
89
|
+
// installWakeAgent intentionally skips launchctl for identical files, so
|
|
90
|
+
// keeping it would make every later retry report success without loading
|
|
91
|
+
// the job.
|
|
92
|
+
try {
|
|
93
|
+
;
|
|
94
|
+
(deps.remove ?? rmSync)(plistPath);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Preserve the launchctl error, which is the actionable failure.
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
export function removeWakeAgent(deps = {}) {
|
|
104
|
+
if (process.platform !== 'darwin' && !deps.plistPath)
|
|
105
|
+
return false;
|
|
106
|
+
const plistPath = deps.plistPath ?? wakeAgentPlistPath();
|
|
107
|
+
if (!(deps.exists ?? existsSync)(plistPath))
|
|
108
|
+
return false;
|
|
109
|
+
const uid = deps.uid ?? process.getuid?.();
|
|
110
|
+
if (uid !== undefined) {
|
|
111
|
+
try {
|
|
112
|
+
;
|
|
113
|
+
(deps.launchctl ?? defaultLaunchctl)(['bootout', `gui/${uid}`, plistPath]);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// An unloaded job is already stopped; the file still needs removal.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
;
|
|
120
|
+
(deps.remove ?? rmSync)(plistPath);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
export async function stampMachineHeartbeat(client, machineId, brokerRunning) {
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
const patch = { waker_seen_at: now };
|
|
126
|
+
if (brokerRunning)
|
|
127
|
+
patch.broker_seen_at = now;
|
|
128
|
+
const { error } = await client.from('machines').update(patch).eq('id', machineId);
|
|
129
|
+
if (error)
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
function assertNoError(error) {
|
|
133
|
+
if (error)
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
function supabaseWakeDb(client, machineId) {
|
|
137
|
+
const table = () => client.from('machine_wake_requests');
|
|
138
|
+
const freshCutoff = () => new Date(Date.now() - WAKE_REQUEST_FRESH_SECONDS * 1000).toISOString();
|
|
139
|
+
return {
|
|
140
|
+
stampHeartbeat: (brokerRunning) => stampMachineHeartbeat(client, machineId, brokerRunning),
|
|
141
|
+
async claimFreshPending() {
|
|
142
|
+
const { data, error } = await table()
|
|
143
|
+
.select('id')
|
|
144
|
+
.eq('machine_id', machineId)
|
|
145
|
+
.eq('status', 'pending')
|
|
146
|
+
.gte('requested_at', freshCutoff())
|
|
147
|
+
.order('requested_at', { ascending: false })
|
|
148
|
+
.limit(1)
|
|
149
|
+
.maybeSingle();
|
|
150
|
+
assertNoError(error);
|
|
151
|
+
return data ?? null;
|
|
152
|
+
},
|
|
153
|
+
async failStalePending() {
|
|
154
|
+
const { error } = await table().update({
|
|
155
|
+
status: 'failed',
|
|
156
|
+
error: 'Request expired before this machine checked in.',
|
|
157
|
+
updated_at: new Date().toISOString(),
|
|
158
|
+
})
|
|
159
|
+
.eq('machine_id', machineId)
|
|
160
|
+
.eq('status', 'pending')
|
|
161
|
+
.lt('requested_at', freshCutoff());
|
|
162
|
+
assertNoError(error);
|
|
163
|
+
},
|
|
164
|
+
async resolvePending(id) {
|
|
165
|
+
const { error } = await table()
|
|
166
|
+
.delete()
|
|
167
|
+
.eq('id', id)
|
|
168
|
+
.eq('machine_id', machineId)
|
|
169
|
+
.eq('status', 'pending');
|
|
170
|
+
assertNoError(error);
|
|
171
|
+
},
|
|
172
|
+
async failRequest(id, message) {
|
|
173
|
+
const { error } = await table()
|
|
174
|
+
.update({ status: 'failed', error: message, updated_at: new Date().toISOString() })
|
|
175
|
+
.eq('id', id);
|
|
176
|
+
assertNoError(error);
|
|
177
|
+
},
|
|
178
|
+
async cleanupFailed() {
|
|
179
|
+
const cutoff = new Date(Date.now() - FAILED_CLEANUP_MINUTES * 60 * 1000).toISOString();
|
|
180
|
+
const { error } = await table()
|
|
181
|
+
.delete()
|
|
182
|
+
.eq('machine_id', machineId)
|
|
183
|
+
.eq('status', 'failed')
|
|
184
|
+
.lt('updated_at', cutoff);
|
|
185
|
+
assertNoError(error);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function defaultGetDb() {
|
|
190
|
+
const client = await getClient();
|
|
191
|
+
return supabaseWakeDb(client, getMachineIdentity().id);
|
|
192
|
+
}
|
|
193
|
+
async function defaultBrokerRunning() {
|
|
194
|
+
const statuses = await runtimeStatuses();
|
|
195
|
+
const machineId = getMachineIdentity().id;
|
|
196
|
+
return statuses.some((status) => status.running && status.health?.machine_id === machineId);
|
|
197
|
+
}
|
|
198
|
+
export async function wakeCheck(deps = {}) {
|
|
199
|
+
try {
|
|
200
|
+
if (await (deps.brokerRunning ?? defaultBrokerRunning)())
|
|
201
|
+
return 'broker_running';
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// A failed probe is treated as not running so the poller can recover it.
|
|
205
|
+
}
|
|
206
|
+
let db;
|
|
207
|
+
try {
|
|
208
|
+
db = await (deps.getDb ?? defaultGetDb)();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return 'no_session';
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await db.stampHeartbeat(false);
|
|
215
|
+
await db.failStalePending();
|
|
216
|
+
const pending = await db.claimFreshPending();
|
|
217
|
+
if (!pending) {
|
|
218
|
+
await db.cleanupFailed();
|
|
219
|
+
return 'idle';
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await (deps.start ?? startRuntime)((deps.runtimeKind ?? currentCompanionKind)());
|
|
223
|
+
await db.resolvePending(pending.id);
|
|
224
|
+
await db.stampHeartbeat(true);
|
|
225
|
+
await db.cleanupFailed();
|
|
226
|
+
return 'started';
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
await db.failRequest(pending.id, error instanceof Error ? error.message : String(error));
|
|
230
|
+
await db.cleanupFailed();
|
|
231
|
+
return 'start_failed';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return 'idle';
|
|
236
|
+
}
|
|
237
|
+
}
|