dsh-codespace-workspace 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,1100 @@
1
+ /**
2
+ * The Codespace workspace manager for dsh-codespace-workspace.
3
+ *
4
+ * Owns everything that has to agree with itself across the plugin's lifetime:
5
+ * the placeholder directories DSH registers as workspaces, the mapping from a
6
+ * DSH workspace id to a Codespace, the auto-pause timers, and the shutdown
7
+ * stop-all.
8
+ *
9
+ * Design notes that are load-bearing:
10
+ *
11
+ * * A Codespace workspace is a *real local directory* that exists and is
12
+ * empty. `dsh-workspace` `realpath`-canonicalizes the path it is given and
13
+ * requires it to be an existing directory, so the placeholder cannot be
14
+ * virtual.
15
+ * * Nothing global is replaced. Routing is by session cwd: a session whose cwd
16
+ * sits under the placeholder root is a Codespace session, and
17
+ * {@link CodespaceManager.isCodespacePath} is the single answer to that
18
+ * question.
19
+ * * Auto-pause is one timer per managed workspace, so two Codespaces never
20
+ * share a countdown.
21
+ *
22
+ * @module dsh-codespace-workspace/host/codespaces
23
+ */
24
+
25
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
26
+ import { homedir } from 'node:os'
27
+ import { join, resolve, sep } from 'node:path'
28
+ import { randomUUID } from 'node:crypto'
29
+
30
+ import { PLACEHOLDER_DIRNAME, SETTINGS_DEFAULTS, canStart, codespacePhase, redact, wireError } from '../shared.js'
31
+ import {
32
+ createCodespace,
33
+ deleteCodespace,
34
+ getPrebuild,
35
+ getViewer,
36
+ GithubError,
37
+ listCodespaces,
38
+ listMachines,
39
+ listRepos,
40
+ repoIsEmpty,
41
+ resolveToken,
42
+ seedEmptyRepo,
43
+ splitRepo,
44
+ startCodespace,
45
+ stopCodespace,
46
+ } from './github.js'
47
+ import { defaultRun, ghInfo } from './gh.js'
48
+
49
+ /** Auto-pause timers never fire sooner than this, however the settings are set. */
50
+ const MIN_PAUSE_MS = 60 * 1000
51
+
52
+ /** Ceiling on one Codespace stop request during teardown. */
53
+ const STOP_ONE_BUDGET_MS = 3500
54
+
55
+ /** Name of the persisted workspace↔Codespace map inside the placeholder root. */
56
+ const MAP_FILENAME = '.managed.json'
57
+
58
+ /**
59
+ * How long a "this Codespace is running" observation is trusted.
60
+ *
61
+ * A turn asks {@link CodespaceManager.ensureRunning} before every model step, and
62
+ * re-listing the account's Codespaces each time would be both slow and rude. A
63
+ * running Codespace stays running for minutes, so a short window is enough to
64
+ * collapse a whole turn into at most one check while still noticing an
65
+ * auto-pause promptly. Every path that stops a Codespace invalidates it anyway.
66
+ */
67
+ const RUNNING_MEMO_MS = 20 * 1000
68
+
69
+ /** How long to wait for a Codespace to become usable before giving up on it. */
70
+ const START_TIMEOUT_MS = 5 * 60 * 1000
71
+
72
+ /** Delay between state polls while waiting for a Codespace to come up. */
73
+ const START_POLL_MS = 3000
74
+
75
+ /** Default README committed into a repository that has no commits. */
76
+ const DEFAULT_README = [
77
+ '# Codespace workspace',
78
+ '',
79
+ 'This repository was initialized by dsh-codespace-workspace so that a GitHub',
80
+ 'Codespace could be created from it: creating a Codespace from a repository',
81
+ 'with no commits fails.',
82
+ '',
83
+ ].join('\n')
84
+
85
+ /**
86
+ * Resolve the profile home that holds the placeholder directory.
87
+ *
88
+ * `dsh-workspace` requires an absolute, existing path, so this must never
89
+ * return something relative. Several shapes are tried because the profile
90
+ * context service is not part of the frozen contract.
91
+ *
92
+ * VERIFY: `profileContext` exposes `home` (used by `dsh-settings`), `dir` and
93
+ * `profileDirectory` (used by `@sutong12/dsh-mcp-lazy`) depending on the
94
+ * build. Tolerant branch: every candidate is probed, then `DSH_HOME`, then
95
+ * `~/.dsh`, so a missing service degrades to the default harness home instead
96
+ * of throwing at load.
97
+ *
98
+ * @param {object} ctx - plugin context.
99
+ * @returns {string} an absolute directory to hold placeholder workspaces.
100
+ */
101
+ export function resolveProfileHome(ctx) {
102
+ const candidates = []
103
+ try {
104
+ const profile = ctx.get?.('profileContext')
105
+ if (profile && typeof profile === 'object') {
106
+ for (const key of ['home', 'dir', 'profileDirectory']) {
107
+ const value = profile[key]
108
+ if (typeof value === 'string' && value.trim().length > 0) candidates.push(value)
109
+ }
110
+ }
111
+ } catch {
112
+ /* service absent or racing teardown */
113
+ }
114
+
115
+ const envHome = process.env.DSH_HOME
116
+ if (typeof envHome === 'string' && envHome.trim().length > 0) {
117
+ const base = envHome.trim()
118
+ const profile = process.env.DSH_PROFILE
119
+ candidates.push(typeof profile === 'string' && profile.trim().length > 0
120
+ ? join(base, 'profiles', profile.trim())
121
+ : base)
122
+ }
123
+ candidates.push(join(homedir(), '.dsh'))
124
+
125
+ for (const candidate of candidates) {
126
+ try {
127
+ return resolve(candidate)
128
+ } catch {
129
+ /* unusable candidate */
130
+ }
131
+ }
132
+ return resolve(homedir())
133
+ }
134
+
135
+ /**
136
+ * Sleep, for the state-polling loop in {@link CodespaceManager.ensureRunning}.
137
+ *
138
+ * The timer is deliberately **not** `unref()`d: an unref'd timer lets a process
139
+ * with nothing else pending exit instead of waiting, which is exactly how a test
140
+ * of this loop would silently stop testing anything. Cancellation is handled by
141
+ * the signal instead, so a shutdown never waits out a poll.
142
+ *
143
+ * @param {number} ms - how long to wait.
144
+ * @param {AbortSignal} [signal] - cut the wait short when this aborts.
145
+ * @returns {Promise<void>} resolves on timeout or abort.
146
+ */
147
+ function sleep(ms, signal) {
148
+ return new Promise((resolve_) => {
149
+ if (signal?.aborted === true) {
150
+ resolve_()
151
+ return
152
+ }
153
+ const onAbort = () => {
154
+ clearTimeout(timer)
155
+ signal?.removeEventListener?.('abort', onAbort)
156
+ resolve_()
157
+ }
158
+ const timer = setTimeout(() => {
159
+ signal?.removeEventListener?.('abort', onAbort)
160
+ resolve_()
161
+ }, ms)
162
+ signal?.addEventListener?.('abort', onAbort, { once: true })
163
+ })
164
+ }
165
+
166
+ /** Make one directory-name-safe token out of a Codespace name. */
167
+ function safeDirName(name) {
168
+ const cleaned = String(name).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '')
169
+ return cleaned.length > 0 ? cleaned.slice(0, 120) : `codespace-${randomUUID().slice(0, 8)}`
170
+ }
171
+
172
+ /**
173
+ * The DSH workspace title for a Codespace: the REPOSITORY's short name.
174
+ *
175
+ * A Codespace's own name is machine-generated (`psychic-goggles-7v9974jwpwr5fpqxq`)
176
+ * and tells the user nothing about which project the row is, so the row is
177
+ * titled after the repository instead — `owner/name` is trimmed to `name`. The
178
+ * full `owner/name` and the Codespace name both remain visible in the hover
179
+ * card, which is where the identifying detail belongs.
180
+ *
181
+ * Falls back to the Codespace name when the repository is unknown (a Codespace
182
+ * still being provisioned reports an empty repository).
183
+ *
184
+ * @param {object} codespace - a normalized Codespace.
185
+ * @returns {string} the title, or `''` when neither source is available.
186
+ */
187
+ export function titleForCodespace(codespace) {
188
+ const source = codespace && typeof codespace === 'object' ? codespace : {}
189
+ // Trailing slashes are stripped first: `owner/name/` would otherwise split to an
190
+ // empty segment and fall through to the Codespace name.
191
+ const repo = typeof source.repository === 'string'
192
+ ? source.repository.trim().replace(/\/+$/, '')
193
+ : ''
194
+ if (repo !== '') {
195
+ const slash = repo.lastIndexOf('/')
196
+ const short = (slash === -1 ? repo : repo.slice(slash + 1)).trim()
197
+ if (short !== '') return short
198
+ }
199
+ const displayName = typeof source.displayName === 'string' ? source.displayName.trim() : ''
200
+ if (displayName !== '') return displayName
201
+ return typeof source.name === 'string' ? source.name.trim() : ''
202
+ }
203
+
204
+ /** Normalize a path for prefix comparison: absolute, no trailing separator. */
205
+ function canonical(path) {
206
+ const resolved = resolve(path)
207
+ return resolved.endsWith(sep) ? resolved.slice(0, -1) : resolved
208
+ }
209
+
210
+ /** Whether `child` is `parent` or sits underneath it. */
211
+ function isUnder(parent, child) {
212
+ const a = canonical(parent)
213
+ const b = canonical(child)
214
+ return b === a || b.startsWith(a + sep)
215
+ }
216
+
217
+ /**
218
+ * Owns the managed Codespace workspaces.
219
+ */
220
+ export class CodespaceManager {
221
+ /**
222
+ * @param {object} ctx - plugin context.
223
+ * @param {{getSettings?: () => object, logger?: object, run?: Function, profileHome?: string}} [options] - manager options.
224
+ */
225
+ constructor(ctx, options = {}) {
226
+ this.ctx = ctx
227
+ this.options = options
228
+ this.run = typeof options.run === 'function' ? options.run : defaultRun
229
+ this.root = join(options.profileHome ?? resolveProfileHome(ctx), PLACEHOLDER_DIRNAME)
230
+ /** @type {Map<string, {workspaceId: string, name: string, title: string, path: string, addedAt: number}>} */
231
+ this.records = new Map()
232
+ /** @type {Map<string, {timer: any, deadline: number, minutes: number}>} */
233
+ this.timers = new Map()
234
+ /** @type {Set<string>} */
235
+ this.busy = new Set()
236
+ /** @type {Set<string>} */
237
+ this.inFlight = new Set()
238
+ /**
239
+ * Codespace name → `Date.now()` until which "it is running" may be assumed.
240
+ * See {@link RUNNING_MEMO_MS}.
241
+ * @type {Map<string, number>}
242
+ */
243
+ this.runningUntil = new Map()
244
+ /** @type {Promise<object>|undefined} Set by the first {@link CodespaceManager.stopAll} call. */
245
+ this.stopAllPromise = undefined
246
+ this.disposed = false
247
+ this.loaded = false
248
+ }
249
+
250
+ /** The configured settings, or the frozen defaults when none are available. */
251
+ settings() {
252
+ const value = typeof this.options.getSettings === 'function' ? this.options.getSettings() : undefined
253
+ return value && typeof value === 'object' ? value : { ...SETTINGS_DEFAULTS }
254
+ }
255
+
256
+ /** Log a line through the plugin logger without ever echoing a credential. */
257
+ log(level, message) {
258
+ const logger = this.options.logger ?? this.ctx?.logger
259
+ try {
260
+ const fn = logger?.[level] ?? logger?.info
261
+ if (typeof fn === 'function') fn.call(logger, redact(`codespace: ${message}`))
262
+ } catch {
263
+ /* a logger is a nicety, never a dependency */
264
+ }
265
+ }
266
+
267
+ // ------------------------------------------------------------- persistence
268
+
269
+ /** Load the workspace↔Codespace map once. */
270
+ ensureLoaded() {
271
+ if (this.loaded) return
272
+ this.loaded = true
273
+ try {
274
+ mkdirSync(this.root, { recursive: true })
275
+ } catch (error) {
276
+ this.log('warn', `cannot create ${this.root}: ${error instanceof Error ? error.message : String(error)}`)
277
+ return
278
+ }
279
+ const file = join(this.root, MAP_FILENAME)
280
+ if (!existsSync(file)) return
281
+ try {
282
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
283
+ const rows = Array.isArray(parsed?.workspaces) ? parsed.workspaces : []
284
+ for (const row of rows) {
285
+ if (!row || typeof row.workspaceId !== 'string' || typeof row.name !== 'string') continue
286
+ this.records.set(row.workspaceId, {
287
+ workspaceId: row.workspaceId,
288
+ name: row.name,
289
+ title: typeof row.title === 'string' ? row.title : row.name,
290
+ path: typeof row.path === 'string' ? row.path : join(this.root, safeDirName(row.name)),
291
+ addedAt: Number.isFinite(row.addedAt) ? row.addedAt : 0,
292
+ })
293
+ }
294
+ } catch (error) {
295
+ // A corrupt map must not stop the plugin: it degrades to "no managed
296
+ // workspaces", and the placeholder directories remain on disk to be
297
+ // re-adopted by a later create.
298
+ this.log('warn', `ignoring unreadable ${MAP_FILENAME}: ${error instanceof Error ? error.message : String(error)}`)
299
+ }
300
+ }
301
+
302
+ /** Persist the workspace↔Codespace map atomically. */
303
+ save() {
304
+ try {
305
+ mkdirSync(this.root, { recursive: true })
306
+ const file = join(this.root, MAP_FILENAME)
307
+ const temporary = `${file}.${randomUUID()}.tmp`
308
+ writeFileSync(temporary, JSON.stringify({ version: 1, workspaces: [...this.records.values()] }, null, 2))
309
+ // renameSync replaces the target atomically on both platforms this ships to.
310
+ renameSync(temporary, file)
311
+ } catch (error) {
312
+ this.log('warn', `cannot persist ${MAP_FILENAME}: ${error instanceof Error ? error.message : String(error)}`)
313
+ }
314
+ }
315
+
316
+ // ------------------------------------------------------------------ lookup
317
+
318
+ /**
319
+ * The workspace id owning a session cwd, when that cwd is a placeholder.
320
+ *
321
+ * @param {string} cwd - the session working directory.
322
+ * @returns {string|undefined} the workspace id, or undefined when this is a local session.
323
+ */
324
+ isCodespacePath(cwd) {
325
+ if (typeof cwd !== 'string' || cwd.length === 0) return undefined
326
+ if (!isUnder(this.root, cwd)) return undefined
327
+ this.ensureLoaded()
328
+ const target = canonical(cwd)
329
+ for (const record of this.records.values()) {
330
+ if (canonical(record.path) === target) return record.workspaceId
331
+ }
332
+ // A placeholder directory exists for a workspace the map lost (a corrupt
333
+ // map, or a directory created by hand). Treating it as a Codespace session
334
+ // is the safe branch: the remote tools are still correct for it, and a
335
+ // local session never has a cwd under this root.
336
+ return undefined
337
+ }
338
+
339
+ /** The managed record for a workspace id. */
340
+ recordOf(workspaceId) {
341
+ this.ensureLoaded()
342
+ return this.records.get(workspaceId)
343
+ }
344
+
345
+ /** The managed record for a Codespace name. */
346
+ recordByName(name) {
347
+ this.ensureLoaded()
348
+ for (const record of this.records.values()) {
349
+ if (record.name === name) return record
350
+ }
351
+ return undefined
352
+ }
353
+
354
+ /** Whether any managed record names this Codespace. */
355
+ isManaged(name) {
356
+ return this.recordByName(name) !== undefined
357
+ }
358
+
359
+ /** Mark a workspace busy (its agent is working) or idle. */
360
+ setBusy(workspaceId, busy) {
361
+ if (busy) this.busy.add(workspaceId)
362
+ else this.busy.delete(workspaceId)
363
+ }
364
+
365
+ // ------------------------------------------------------------- token/status
366
+
367
+ /**
368
+ * Resolve the token or throw an actionable error.
369
+ *
370
+ * @returns {Promise<{token: string, source: string}>} the token and its source.
371
+ * @throws {GithubError} `E_NO_TOKEN` when no source produced one.
372
+ */
373
+ async requireToken() {
374
+ const settings = this.settings()
375
+ const resolved = await resolveToken(this.ctx, settings, this.run)
376
+ if (resolved.token === null) {
377
+ throw new GithubError(
378
+ 'no GitHub token: set one in the Codespace settings, export GITHUB_TOKEN, or sign in with the GitHub CLI (gh)',
379
+ 'E_NO_TOKEN',
380
+ )
381
+ }
382
+ return { token: resolved.token, source: resolved.source }
383
+ }
384
+
385
+ /**
386
+ * Plugin status for the settings page.
387
+ *
388
+ * @returns {Promise<{gh: object, tokenSource: string, configured: boolean, login: string}>} the status.
389
+ */
390
+ async status() {
391
+ const settings = this.settings()
392
+ const gh = await ghInfo(this.run)
393
+ const resolved = await resolveToken(this.ctx, settings, this.run)
394
+
395
+ let login = ''
396
+ if (resolved.token !== null) {
397
+ try {
398
+ const viewer = await getViewer(resolved.token)
399
+ login = viewer.login
400
+ } catch (error) {
401
+ // A bad token is a status fact, not a status failure: the page renders
402
+ // "not signed in" and the actionable message comes from the action the
403
+ // user actually takes.
404
+ this.log('warn', `token rejected while reading the viewer: ${error instanceof Error ? error.message : String(error)}`)
405
+ }
406
+ }
407
+
408
+ return {
409
+ gh: {
410
+ installed: gh.installed === true,
411
+ version: gh.version ?? '',
412
+ authenticated: gh.authenticated === true,
413
+ login: gh.login ?? '',
414
+ },
415
+ tokenSource: resolved.source,
416
+ configured: resolved.token !== null,
417
+ login,
418
+ }
419
+ }
420
+
421
+ // ------------------------------------------------------------ control plane
422
+
423
+ /** Repositories the token can reach. */
424
+ async listRepos() {
425
+ const { token } = await this.requireToken()
426
+ return listRepos(token)
427
+ }
428
+
429
+ /**
430
+ * Codespaces, annotated with whether this plugin manages them.
431
+ *
432
+ * @param {string} [repo] - restrict to one repository.
433
+ * @returns {Promise<object[]>} normalized Codespaces.
434
+ */
435
+ async listCodespaces(repo) {
436
+ const { token } = await this.requireToken()
437
+ const rows = await listCodespaces(token, repo)
438
+ return rows.map((row) => ({ ...row, managed: this.isManaged(row.name) }))
439
+ }
440
+
441
+ /** Machine types for a repository. */
442
+ async machines(repo) {
443
+ const { token } = await this.requireToken()
444
+ const settings = this.settings()
445
+ const branch = settings.defaultBranch
446
+ return listMachines(token, repo, branch)
447
+ }
448
+
449
+ /**
450
+ * Whether a repository has a ready prebuild.
451
+ *
452
+ * @param {string} repo - `owner/name`.
453
+ * @returns {Promise<boolean|null>} true/false, or null when it cannot be determined.
454
+ */
455
+ async prebuild(repo) {
456
+ const { token } = await this.requireToken()
457
+ const settings = this.settings()
458
+ return getPrebuild(token, repo, settings.defaultBranch)
459
+ }
460
+
461
+ /**
462
+ * Create a Codespace, seeding an empty repository first when configured to.
463
+ *
464
+ * Creating a Codespace from a repository with no commits fails, so when the
465
+ * repository is empty and `autoInitEmptyRepo` is on, a README is committed
466
+ * through the Contents API first. No `branch` is sent on that initial
467
+ * commit: the Contents API rejects a branch that does not exist yet.
468
+ *
469
+ * @param {{repo: string, branch?: string, machine?: string, displayName?: string}} options - creation inputs.
470
+ * @returns {Promise<object>} the created Codespace.
471
+ */
472
+ async create(options) {
473
+ const { token } = await this.requireToken()
474
+ const settings = this.settings()
475
+ const repo = String(options.repo ?? '')
476
+ splitRepo(repo)
477
+
478
+ let seeded = false
479
+ try {
480
+ if (await repoIsEmpty(token, repo)) {
481
+ if (settings.autoInitEmptyRepo === false) {
482
+ throw new GithubError(
483
+ `${repo} has no commits; creating a Codespace from it will fail. Enable "initialize empty repositories" or push an initial commit first.`,
484
+ 'E_EMPTY_REPO',
485
+ )
486
+ }
487
+ const content = typeof settings.readmeContent === 'string' && settings.readmeContent.length > 0
488
+ ? settings.readmeContent
489
+ : DEFAULT_README
490
+ await seedEmptyRepo(token, { repo, content })
491
+ seeded = true
492
+ }
493
+ } catch (error) {
494
+ // A 404/409 from the commits probe already means "empty" inside
495
+ // repoIsEmpty. Anything that escapes is a real failure to determine
496
+ // emptiness; letting it through is safer than creating a Codespace that
497
+ // is guaranteed to fail.
498
+ if (error instanceof GithubError && error.code === 'E_EMPTY_REPO') throw error
499
+ this.log('warn', `emptiness probe failed for ${repo}: ${error instanceof Error ? error.message : String(error)}`)
500
+ throw error
501
+ }
502
+
503
+ const idle = Number.isFinite(settings.autoPauseMinutes)
504
+ ? Math.max(5, Math.round(settings.autoPauseMinutes))
505
+ : SETTINGS_DEFAULTS.autoPauseMinutes
506
+
507
+ const result = await createCodespace(token, {
508
+ repo,
509
+ branch: options.branch ?? settings.defaultBranch,
510
+ machine: options.machine,
511
+ displayName: options.displayName,
512
+ idleTimeoutMinutes: idle,
513
+ })
514
+ if (seeded) this.log('info', `initialized empty repository ${repo} with a README before creating the Codespace`)
515
+ return { ...result.codespace, managed: this.isManaged(result.codespace.name) }
516
+ }
517
+
518
+ /** Start a Codespace. */
519
+ async start(name) {
520
+ const { token } = await this.requireToken()
521
+ const row = await startCodespace(token, name)
522
+ return { ...row, managed: this.isManaged(row.name) }
523
+ }
524
+
525
+ /** Forget a remembered "running" observation. */
526
+ forgetRunning(name) {
527
+ this.runningUntil.delete(name)
528
+ }
529
+
530
+ /**
531
+ * The live Codespace row for one name, or `null` when GitHub no longer has it.
532
+ *
533
+ * @param {string} name - the Codespace name.
534
+ * @returns {Promise<object|null>} the normalized row.
535
+ */
536
+ async liveCodespace(name) {
537
+ const rows = await this.listCodespaces()
538
+ return rows.find((row) => row.name === name) ?? null
539
+ }
540
+
541
+ /**
542
+ * Make sure one workspace's Codespace is running, starting it when it is not.
543
+ *
544
+ * This is what lets a user just type into a Codespace session whose Codespace
545
+ * is asleep: the message waits for the machine instead of failing every tool
546
+ * call with a connection error. It is called from the `agent/pre-step`
547
+ * waterfall, which the agent loop **awaits** before it asks the model for a
548
+ * step (verified in `dsh-agent-loop/lib/index.js` ≈L911), so returning late
549
+ * genuinely delays the turn rather than racing it.
550
+ *
551
+ * Never throws: a Codespace that cannot be started is reported as
552
+ * `{ ok: false, reason }` and the turn proceeds, so the model sees the real
553
+ * transport error rather than a silently blocked turn.
554
+ *
555
+ * @param {string} workspaceId - the managed workspace id.
556
+ * @param {{signal?: AbortSignal, timeoutMs?: number, pollMs?: number}} [options] - bounds.
557
+ * @returns {Promise<{ok: boolean, reason: string, state?: string}>} the outcome.
558
+ */
559
+ async ensureRunning(workspaceId, options = {}) {
560
+ const record = this.recordOf(workspaceId)
561
+ if (record === undefined) return { ok: false, reason: 'unmanaged' }
562
+
563
+ const signal = options.signal
564
+ const remembered = this.runningUntil.get(record.name)
565
+ if (remembered !== undefined && Date.now() < remembered) return { ok: true, reason: 'remembered' }
566
+
567
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : START_TIMEOUT_MS
568
+ const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : START_POLL_MS
569
+ const deadline = Date.now() + Math.max(0, timeoutMs)
570
+ const aborted = () => signal?.aborted === true
571
+
572
+ let row
573
+ try {
574
+ row = await this.liveCodespace(record.name)
575
+ } catch (error) {
576
+ return { ok: false, reason: 'unreachable', state: error instanceof Error ? error.message : String(error) }
577
+ }
578
+ if (row === null) return { ok: false, reason: 'missing' }
579
+ if (codespacePhase(row.state) === 'running') {
580
+ this.runningUntil.set(record.name, Date.now() + RUNNING_MEMO_MS)
581
+ return { ok: true, reason: 'running' }
582
+ }
583
+
584
+ // Start it when GitHub says that is a legal thing to ask for. A `pending`
585
+ // state means the transition is already under way, so it is waited out
586
+ // rather than re-requested.
587
+ let started = false
588
+ if (canStart(codespacePhase(row.state))) {
589
+ if (aborted()) return { ok: false, reason: 'aborted', state: row.state }
590
+ this.log('info', `starting ${record.name} because a turn needs it (state ${row.state})`)
591
+ try {
592
+ await this.start(record.name)
593
+ started = true
594
+ } catch (error) {
595
+ return { ok: false, reason: 'start-failed', state: error instanceof Error ? error.message : String(error) }
596
+ }
597
+ }
598
+
599
+ let last = row.state
600
+ while (Date.now() < deadline) {
601
+ if (aborted()) return { ok: false, reason: 'aborted', state: last }
602
+ await sleep(pollMs, signal)
603
+ if (aborted()) return { ok: false, reason: 'aborted', state: last }
604
+ try {
605
+ row = await this.liveCodespace(record.name)
606
+ } catch (error) {
607
+ return { ok: false, reason: 'unreachable', state: error instanceof Error ? error.message : String(error) }
608
+ }
609
+ if (row === null) return { ok: false, reason: 'missing' }
610
+ last = row.state
611
+ const phase = codespacePhase(row.state)
612
+ if (phase === 'running') {
613
+ this.runningUntil.set(record.name, Date.now() + RUNNING_MEMO_MS)
614
+ this.log('info', `${record.name} is available${started ? ' after starting it' : ''}`)
615
+ return { ok: true, reason: started ? 'started' : 'became-running' }
616
+ }
617
+ if (phase === 'error') return { ok: false, reason: 'failed', state: row.state }
618
+ }
619
+ return { ok: false, reason: 'timeout', state: last }
620
+ }
621
+
622
+ /** Stop a Codespace and cancel any pending auto-pause for it. */
623
+ async stop(name) {
624
+ const record = this.recordByName(name)
625
+ if (record !== undefined) this.cancelAutoPause(record.workspaceId)
626
+ this.forgetRunning(name)
627
+ const { token } = await this.requireToken()
628
+ const row = await stopCodespace(token, name)
629
+ return { ...row, managed: this.isManaged(row.name) }
630
+ }
631
+
632
+ /** Delete a Codespace. */
633
+ async remove(name) {
634
+ const record = this.recordByName(name)
635
+ if (record !== undefined) this.cancelAutoPause(record.workspaceId)
636
+ this.forgetRunning(name)
637
+ const { token } = await this.requireToken()
638
+ await deleteCodespace(token, name)
639
+ }
640
+
641
+ // --------------------------------------------------------------- workspaces
642
+
643
+ /**
644
+ * Every managed workspace, joined with its live Codespace state.
645
+ *
646
+ * @returns {Promise<object[]>} `Managed[]`.
647
+ */
648
+ async listWorkspaces() {
649
+ this.ensureLoaded()
650
+ const records = [...this.records.values()]
651
+ if (records.length === 0) return []
652
+
653
+ // One list call serves every row: a per-row request would make the 4s
654
+ // sidebar poll quadratic in the number of workspaces.
655
+ const byName = new Map()
656
+ let listError = null
657
+ try {
658
+ for (const row of await this.listCodespaces()) byName.set(row.name, row)
659
+ } catch (error) {
660
+ listError = wireError(error)
661
+ }
662
+
663
+ // Retitle rows registered before the repository-based naming (or before the
664
+ // Codespace's repository was known). The DSH registry keeps the title of an
665
+ // existing record, so without this the user's existing rows would keep their
666
+ // machine-generated Codespace name forever.
667
+ await this.retitleFromLive(byName)
668
+
669
+ return records.map((record) => {
670
+ const live = byName.get(record.name)
671
+ const state = live?.state ?? ''
672
+ return {
673
+ workspaceId: record.workspaceId,
674
+ title: record.title,
675
+ path: record.path,
676
+ codespace: live ?? {
677
+ name: record.name,
678
+ displayName: record.name,
679
+ repository: '',
680
+ branch: '',
681
+ state: '',
682
+ machine: { name: '', displayName: '', cpus: 0, memoryGb: 0 },
683
+ idleTimeoutMinutes: 0,
684
+ lastUsedAt: '',
685
+ createdAt: '',
686
+ webUrl: '',
687
+ prebuild: null,
688
+ managed: true,
689
+ },
690
+ state,
691
+ phase: codespacePhase(state),
692
+ busy: this.busy.has(record.workspaceId),
693
+ ...this.autoPauseInfo(record.workspaceId),
694
+ ...(listError === null ? {} : { error: listError }),
695
+ }
696
+ })
697
+ }
698
+
699
+ /**
700
+ * Bring each managed record's title in line with its Codespace's repository.
701
+ *
702
+ * Idempotent and cheap when nothing changed: it only writes when a title
703
+ * actually differs. A record whose Codespace is gone, or whose repository is
704
+ * still unknown, is left alone — guessing a title from nothing would be worse
705
+ * than the old one.
706
+ *
707
+ * @param {Map<string, object>} byName - live Codespaces keyed by name.
708
+ * @returns {Promise<void>}
709
+ */
710
+ async retitleFromLive(byName) {
711
+ if (byName.size === 0) return
712
+ const registry = this.ctx.get?.('workspaceRegistry')
713
+ let changed = false
714
+ for (const record of this.records.values()) {
715
+ const live = byName.get(record.name)
716
+ if (live === undefined) continue
717
+ const wanted = titleForCodespace(live)
718
+ if (wanted === '' || wanted === record.title) continue
719
+ if (registry !== undefined && typeof registry.resolveByPath === 'function') {
720
+ try {
721
+ const workspace = await registry.resolveByPath(record.path)
722
+ if (workspace !== undefined && typeof workspace.setTitle === 'function') {
723
+ await workspace.setTitle(wanted)
724
+ }
725
+ } catch (error) {
726
+ this.log('warn', `cannot retitle workspace ${record.workspaceId}: ${error instanceof Error ? error.message : String(error)}`)
727
+ continue
728
+ }
729
+ }
730
+ record.title = wanted
731
+ changed = true
732
+ }
733
+ if (changed) this.save()
734
+ }
735
+
736
+ /**
737
+ * Create the placeholder directory and register it as a DSH workspace.
738
+ *
739
+ * @param {{codespace: object|string, title?: string}} options - the Codespace and an optional title.
740
+ * @returns {Promise<object>} the `Managed` record.
741
+ */
742
+ async createWorkspace(options) {
743
+ this.ensureLoaded()
744
+ const codespace = options.codespace
745
+ const name = typeof codespace === 'string' ? codespace : String(codespace?.name ?? '')
746
+ if (name.length === 0) {
747
+ throw new GithubError('create-workspace requires a Codespace with a name', 'E_INVALID')
748
+ }
749
+
750
+ const registry = this.ctx.get?.('workspaceRegistry')
751
+ if (registry === undefined || typeof registry.create !== 'function') {
752
+ throw new GithubError('the workspace registry service is unavailable in this profile', 'E_NO_REGISTRY')
753
+ }
754
+
755
+ const dir = join(this.root, safeDirName(name))
756
+ mkdirSync(dir, { recursive: true })
757
+
758
+ // The row is titled after the REPOSITORY, not the machine-generated Codespace
759
+ // name; see `titleForCodespace`. An explicit `title` still wins, and when the
760
+ // Codespace carries no repository yet the Codespace name is the fallback.
761
+ const live = typeof codespace === 'object' && codespace !== null ? codespace : undefined
762
+ const title = typeof options.title === 'string' && options.title.trim().length > 0
763
+ ? options.title.trim()
764
+ : titleForCodespace(live ?? { name })
765
+
766
+ // `create` returns the existing workspace when one already owns this path
767
+ // (without changing its title), so this is idempotent and safe to retry.
768
+ // `resolveByPath` is probed first because it is the pure "already adopted?"
769
+ // question: it neither creates nor mutates.
770
+ let workspace
771
+ if (typeof registry.resolveByPath === 'function') {
772
+ workspace = await registry.resolveByPath(dir)
773
+ }
774
+ if (workspace === undefined) workspace = await registry.create(dir, title)
775
+ const workspaceId = String(workspace.id)
776
+
777
+ // `create` keeps the title of an existing record, so a workspace that was
778
+ // registered under the old Codespace-name title is retitled here — that is
779
+ // what makes the rename apply to rows the user already has.
780
+ if (title !== '' && typeof workspace.setTitle === 'function') {
781
+ const current = typeof workspace.title === 'string' ? workspace.title : ''
782
+ if (current !== title) {
783
+ try {
784
+ await workspace.setTitle(title)
785
+ } catch (error) {
786
+ this.log('warn', `cannot retitle workspace ${workspaceId} to "${title}": ${error instanceof Error ? error.message : String(error)}`)
787
+ }
788
+ }
789
+ }
790
+
791
+ this.records.set(workspaceId, { workspaceId, name, title, path: dir, addedAt: Date.now() })
792
+ this.save()
793
+ this.log('info', `registered workspace ${workspaceId} for Codespace ${name} at ${dir}`)
794
+
795
+ return {
796
+ workspaceId,
797
+ title,
798
+ path: dir,
799
+ codespace: live ?? {
800
+ name,
801
+ displayName: name,
802
+ repository: '',
803
+ branch: '',
804
+ state: '',
805
+ machine: { name: '', displayName: '', cpus: 0, memoryGb: 0 },
806
+ idleTimeoutMinutes: 0,
807
+ lastUsedAt: '',
808
+ createdAt: '',
809
+ webUrl: '',
810
+ prebuild: null,
811
+ managed: true,
812
+ },
813
+ state: live?.state ?? '',
814
+ busy: false,
815
+ ...this.autoPauseInfo(workspaceId),
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Remove a managed workspace, optionally deleting its Codespace.
821
+ *
822
+ * The registry's `delete` is an idempotent no-op for an unknown id, so a
823
+ * repeated removal does not throw.
824
+ *
825
+ * @param {{workspaceId: string, deleteCodespace?: boolean}} options - what to remove.
826
+ * @returns {Promise<{}>} an empty result.
827
+ */
828
+ async removeWorkspace(options) {
829
+ this.ensureLoaded()
830
+ const workspaceId = String(options.workspaceId ?? '')
831
+ const record = this.records.get(workspaceId)
832
+
833
+ this.cancelAutoPause(workspaceId)
834
+ this.busy.delete(workspaceId)
835
+
836
+ const registry = this.ctx.get?.('workspaceRegistry')
837
+ if (registry !== undefined && typeof registry.delete === 'function') {
838
+ try {
839
+ // `delete` returns false for an unknown id and never throws, so this
840
+ // needs no existence pre-check; the call is kept inside try/catch only
841
+ // so an unexpected storage failure cannot strand the local record.
842
+ await registry.delete(workspaceId)
843
+ } catch (error) {
844
+ this.log('warn', `registry.delete(${workspaceId}) failed: ${error instanceof Error ? error.message : String(error)}`)
845
+ }
846
+ }
847
+
848
+ this.records.delete(workspaceId)
849
+ this.save()
850
+
851
+ if (options.deleteCodespace === true && record !== undefined) {
852
+ const { token } = await this.requireToken()
853
+ await deleteCodespace(token, record.name)
854
+ }
855
+
856
+ if (record !== undefined) {
857
+ try {
858
+ rmSync(record.path, { recursive: true, force: true })
859
+ } catch (error) {
860
+ // The directory may be locked by a live session; leaving an empty
861
+ // placeholder behind is harmless and re-adopted on the next create.
862
+ this.log('warn', `cannot remove placeholder ${record.path}: ${error instanceof Error ? error.message : String(error)}`)
863
+ }
864
+ }
865
+ return {}
866
+ }
867
+
868
+ // --------------------------------------------------------------- auto-pause
869
+
870
+ /** The countdown state for a workspace, in the shape `Managed` publishes. */
871
+ autoPauseInfo(workspaceId) {
872
+ const entry = this.timers.get(workspaceId)
873
+ if (entry === undefined) return { autoPauseRemainingMs: 0, autoPauseDeadline: 0 }
874
+ return {
875
+ autoPauseRemainingMs: Math.max(0, entry.deadline - Date.now()),
876
+ autoPauseDeadline: entry.deadline,
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Start or restart this workspace's auto-pause countdown.
882
+ *
883
+ * Called on `turn/end`. A second call restarts the window rather than
884
+ * stacking a second timer: there is exactly one timer per workspace.
885
+ *
886
+ * @param {string} workspaceId - the workspace whose agent just went idle.
887
+ */
888
+ armAutoPause(workspaceId) {
889
+ if (this.disposed) return
890
+ const record = this.records.get(workspaceId)
891
+ if (record === undefined) return
892
+
893
+ const settings = this.settings()
894
+ const minutes = Number.isFinite(settings.autoPauseMinutes)
895
+ ? settings.autoPauseMinutes
896
+ : SETTINGS_DEFAULTS.autoPauseMinutes
897
+ if (!(minutes > 0)) {
898
+ this.cancelAutoPause(workspaceId)
899
+ return
900
+ }
901
+
902
+ const delay = Math.max(MIN_PAUSE_MS, Math.round(minutes * 60 * 1000))
903
+ this.cancelAutoPause(workspaceId)
904
+
905
+ const timer = setTimeout(() => {
906
+ this.timers.delete(workspaceId)
907
+ void this.pauseNow(workspaceId).catch((error) => {
908
+ this.log('warn', `auto-pause of ${record.name} failed: ${error instanceof Error ? error.message : String(error)}`)
909
+ })
910
+ }, delay)
911
+ // A pending countdown must never hold the process open.
912
+ if (typeof timer.unref === 'function') timer.unref()
913
+
914
+ this.timers.set(workspaceId, { timer, deadline: Date.now() + delay, minutes })
915
+ }
916
+
917
+ /**
918
+ * Cancel this workspace's countdown.
919
+ *
920
+ * @param {string} workspaceId - the workspace.
921
+ * @returns {boolean} whether a countdown was running.
922
+ */
923
+ cancelAutoPause(workspaceId) {
924
+ const entry = this.timers.get(workspaceId)
925
+ if (entry === undefined) return false
926
+ clearTimeout(entry.timer)
927
+ this.timers.delete(workspaceId)
928
+ return true
929
+ }
930
+
931
+ /**
932
+ * Stop this workspace's Codespace now, cancelling its countdown.
933
+ *
934
+ * @param {string} workspaceId - the workspace.
935
+ * @returns {Promise<{stopped: boolean, name: string}>} the outcome.
936
+ */
937
+ async pauseNow(workspaceId) {
938
+ this.cancelAutoPause(workspaceId)
939
+ const record = this.records.get(workspaceId)
940
+ if (record === undefined) return { stopped: false, name: '' }
941
+ // The memo must not outlive a stop we asked for: a turn starting right after
942
+ // an auto-pause has to see the stopped state and start the machine again.
943
+ this.forgetRunning(record.name)
944
+
945
+ try {
946
+ const { token } = await this.requireToken()
947
+ const row = await stopCodespace(token, record.name)
948
+ this.log('info', `auto-paused ${record.name} (state ${row.state})`)
949
+ return { stopped: true, name: record.name }
950
+ } catch (error) {
951
+ // A stop that fails is logged, never thrown: this runs from a timer and
952
+ // from a double-click, and neither has anywhere to surface a rejection.
953
+ this.log('warn', `cannot stop ${record.name}: ${error instanceof Error ? error.message : String(error)}`)
954
+ return { stopped: false, name: record.name }
955
+ }
956
+ }
957
+
958
+ // ----------------------------------------------------------------- shutdown
959
+
960
+ /**
961
+ * Stop every managed Codespace. Idempotent, concurrent, bounded, never rejects.
962
+ *
963
+ * Runs at most once per process: the promise is latched, so a second caller
964
+ * (a repeat teardown, a signal path racing the effect disposer) awaits the
965
+ * first result instead of issuing a second round of stop requests.
966
+ *
967
+ * The budget here is the harness's, not ours: `createProcessShutdown` arms a
968
+ * hard 5s timer that calls `process.exit()` if dispose has not settled, and
969
+ * the crash path allows only 2s. So every stop is issued *concurrently*
970
+ * through `Promise.allSettled`, each is bounded by {@link STOP_ONE_BUDGET_MS},
971
+ * and no failure escapes — a rejected disposer would surface as an
972
+ * `AggregateError` out of `runProfile`.
973
+ *
974
+ * @param {string} [reason] - recorded in the log line and the pending marker.
975
+ * @returns {Promise<{attempted: number, stopped: number, pending: string[]}>} the outcome.
976
+ */
977
+ async stopAll(reason = 'shutdown') {
978
+ if (this.stopAllPromise !== undefined) return this.stopAllPromise
979
+ this.stopAllPromise = this.#stopAll(reason).catch((error) => {
980
+ // Belt and braces: #stopAll already swallows everything, but this method
981
+ // is the last thing between a bug here and a failed process teardown.
982
+ this.log('warn', `stopAll failed: ${error instanceof Error ? error.message : String(error)}`)
983
+ return { attempted: 0, stopped: 0, pending: [] }
984
+ })
985
+ return this.stopAllPromise
986
+ }
987
+
988
+ /** The body of {@link CodespaceManager.stopAll}, run at most once. */
989
+ async #stopAll(reason) {
990
+ this.ensureLoaded()
991
+ for (const workspaceId of [...this.timers.keys()]) this.cancelAutoPause(workspaceId)
992
+
993
+ const names = [...new Set([...this.records.values()].map((record) => record.name))]
994
+ if (names.length === 0) return { attempted: 0, stopped: 0, pending: [] }
995
+ for (const name of names) this.forgetRunning(name)
996
+
997
+ let token = null
998
+ try {
999
+ token = (await this.requireToken()).token
1000
+ } catch (error) {
1001
+ // No token means we cannot stop anything. Record every managed name as
1002
+ // pending so the next boot's leftover prompt covers them.
1003
+ this.log('warn', `not stopping ${names.length} Codespace(s) on ${reason}: ${error instanceof Error ? error.message : String(error)}`)
1004
+ this.writePendingStop(names, reason)
1005
+ return { attempted: names.length, stopped: 0, pending: names }
1006
+ }
1007
+
1008
+ const settled = await Promise.allSettled(names.map(async (name) => {
1009
+ await stopCodespace(token, name, {
1010
+ // Under the 5s process budget, with headroom: a stop that has not
1011
+ // answered by then will not answer usefully.
1012
+ timeoutMs: STOP_ONE_BUDGET_MS,
1013
+ retry: false,
1014
+ })
1015
+ return name
1016
+ }))
1017
+
1018
+ const pending = []
1019
+ let stopped = 0
1020
+ for (let index = 0; index < settled.length; index += 1) {
1021
+ const outcome = settled[index]
1022
+ if (outcome.status === 'fulfilled') {
1023
+ stopped += 1
1024
+ continue
1025
+ }
1026
+ pending.push(names[index])
1027
+ this.log('warn', `cannot stop ${names[index]} on ${reason}: ${outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)}`)
1028
+ }
1029
+
1030
+ // The safety net the contract requires: a stop that did not confirm is
1031
+ // recorded so the next boot can offer it alongside the live listing.
1032
+ if (pending.length > 0) this.writePendingStop(pending, reason)
1033
+ else this.clearPendingStop()
1034
+
1035
+ this.log('info', `${reason}: stopped ${stopped}/${names.length} managed Codespace(s)${pending.length > 0 ? `; ${pending.length} recorded as pending` : ''}`)
1036
+ return { attempted: names.length, stopped, pending }
1037
+ }
1038
+
1039
+ /** Path of the pending-stop marker. */
1040
+ get pendingStopFile() {
1041
+ return join(this.root, 'pending-stop.json')
1042
+ }
1043
+
1044
+ /**
1045
+ * Record Codespace names whose stop did not confirm.
1046
+ *
1047
+ * Best effort by construction: this runs inside a hard shutdown budget, so a
1048
+ * failure to write the marker is logged and nothing else.
1049
+ *
1050
+ * @param {string[]} names - the Codespace names still believed running.
1051
+ * @param {string} reason - why the stop was attempted.
1052
+ */
1053
+ writePendingStop(names, reason) {
1054
+ try {
1055
+ mkdirSync(this.root, { recursive: true })
1056
+ const file = this.pendingStopFile
1057
+ const temporary = `${file}.${randomUUID()}.tmp`
1058
+ writeFileSync(temporary, JSON.stringify({ version: 1, at: new Date().toISOString(), reason, names }, null, 2))
1059
+ renameSync(temporary, file)
1060
+ } catch (error) {
1061
+ this.log('warn', `cannot record pending stops: ${error instanceof Error ? error.message : String(error)}`)
1062
+ }
1063
+ }
1064
+
1065
+ /** Drop the pending-stop marker, if any. */
1066
+ clearPendingStop() {
1067
+ try {
1068
+ rmSync(this.pendingStopFile, { force: true })
1069
+ } catch {
1070
+ /* the marker is a hint, never a requirement */
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Codespace names whose stop a previous process left unfinished.
1076
+ *
1077
+ * @returns {string[]} the recorded names, or an empty array.
1078
+ */
1079
+ readPendingStop() {
1080
+ try {
1081
+ if (!existsSync(this.pendingStopFile)) return []
1082
+ const parsed = JSON.parse(readFileSync(this.pendingStopFile, 'utf8'))
1083
+ const names = Array.isArray(parsed?.names) ? parsed.names : []
1084
+ return names.filter((name) => typeof name === 'string' && name.length > 0)
1085
+ } catch {
1086
+ // An unreadable marker is discarded rather than retried forever.
1087
+ this.clearPendingStop()
1088
+ return []
1089
+ }
1090
+ }
1091
+
1092
+ /** Cancel every timer and mark the manager closed. Idempotent. */
1093
+ dispose() {
1094
+ if (this.disposed) return
1095
+ this.disposed = true
1096
+ for (const workspaceId of [...this.timers.keys()]) this.cancelAutoPause(workspaceId)
1097
+ this.busy.clear()
1098
+ this.runningUntil.clear()
1099
+ }
1100
+ }