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