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.
- package/LICENSE +21 -0
- package/README.md +680 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +10 -0
- package/lib/client.js +3598 -0
- package/lib/host/codespaces.js +942 -0
- package/lib/host/gh.js +271 -0
- package/lib/host/github.js +619 -0
- package/lib/host/remote-tools.js +565 -0
- package/lib/host/routes.js +555 -0
- package/lib/host/session.js +267 -0
- package/lib/host/transport.js +463 -0
- package/lib/index.js +170 -0
- package/lib/shared.js +181 -0
- package/locale/en.json +122 -0
- package/locale/zh.json +122 -0
- package/package.json +80 -4
|
@@ -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
|
+
}
|