dsh-plugin-syncup 0.0.0-stage → 4.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/lib/github.js ADDED
@@ -0,0 +1,305 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { emptyManifest, normalizeManifest } from './manifest.js'
3
+ import { expandHome } from './spec.js'
4
+
5
+ const USER_AGENT = 'dsh-plugin-syncup/2.2.0'
6
+ const MAX_MANIFEST_BYTES = 1_000_000
7
+
8
+ export class GitHubError extends Error {
9
+ constructor(message, { code = 'github-error', status = undefined, retryable = false, body = undefined } = {}) {
10
+ super(message)
11
+ this.name = 'GitHubError'
12
+ this.code = code
13
+ this.status = status
14
+ this.retryable = retryable
15
+ this.body = body
16
+ }
17
+ }
18
+
19
+ export function resolveToken({ token = '', tokenEnv = 'GITHUB_TOKEN', tokenFile = '', env = process.env } = {}) {
20
+ if (typeof token === 'string' && token.trim() !== '') {
21
+ return { token: token.trim(), source: 'config' }
22
+ }
23
+ if (typeof tokenEnv === 'string' && tokenEnv.trim() !== '') {
24
+ const value = env[tokenEnv.trim()]
25
+ if (typeof value === 'string' && value.trim() !== '') {
26
+ return { token: value.trim(), source: `env:${tokenEnv.trim()}` }
27
+ }
28
+ }
29
+ const file = expandHome(tokenFile)
30
+ if (file !== '') {
31
+ try {
32
+ const value = readFileSync(file, 'utf8').trim()
33
+ if (value !== '') return { token: value, source: 'file' }
34
+ } catch {
35
+ return { token: '', source: null }
36
+ }
37
+ }
38
+ return { token: '', source: null }
39
+ }
40
+
41
+ function parseApiBase(value) {
42
+ const fallback = 'https://api.github.com'
43
+ if (typeof value !== 'string' || value.trim() === '') return fallback
44
+ let url
45
+ try {
46
+ url = new URL(value.trim())
47
+ } catch {
48
+ return fallback
49
+ }
50
+ url.search = ''
51
+ url.hash = ''
52
+ return url.toString().replace(/\/+$/, '')
53
+ }
54
+
55
+ function errorMessageFromBody(body, status) {
56
+ if (typeof body === 'string' && body.trim() !== '') return body.trim().slice(0, 500)
57
+ if (body !== null && typeof body === 'object' && typeof body.message === 'string') {
58
+ const details = Array.isArray(body.errors)
59
+ ? body.errors
60
+ .map((item) => (typeof item === 'string' ? item : item?.message ?? item?.code ?? ''))
61
+ .filter((item) => typeof item === 'string' && item !== '')
62
+ : []
63
+ const suffix = details.length === 0 ? '' : ` (${details.join('; ')})`
64
+ return `${body.message}${suffix}`.slice(0, 500)
65
+ }
66
+ return `GitHub API request failed with status ${status}`
67
+ }
68
+
69
+ function gistVersion(gist) {
70
+ if (gist !== null && typeof gist === 'object' && Array.isArray(gist.history)) {
71
+ const version = gist.history[0]?.version
72
+ if (typeof version === 'string' && version !== '') return version
73
+ }
74
+ return typeof gist?.updated_at === 'string' ? gist.updated_at : null
75
+ }
76
+
77
+ function gistSummary(gist) {
78
+ return {
79
+ gistId: typeof gist?.id === 'string' ? gist.id : null,
80
+ htmlUrl: typeof gist?.html_url === 'string' ? gist.html_url : null,
81
+ description: typeof gist?.description === 'string' ? gist.description : null,
82
+ updatedAt: typeof gist?.updated_at === 'string' ? gist.updated_at : null,
83
+ version: gistVersion(gist),
84
+ public: gist?.public === true,
85
+ }
86
+ }
87
+
88
+ function gistFileName(value) {
89
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : 'dsh-plugins.json'
90
+ }
91
+
92
+ export function createGitHubClient({
93
+ apiBaseUrl = 'https://api.github.com',
94
+ token = '',
95
+ timeoutMs = 20_000,
96
+ fetchImpl = globalThis.fetch,
97
+ } = {}) {
98
+ if (typeof fetchImpl !== 'function') throw new GitHubError('fetch is unavailable in this Host', { code: 'no-fetch' })
99
+ const base = parseApiBase(apiBaseUrl)
100
+
101
+ async function requestUrl(url, { method = 'GET', body, headers = {}, allow404 = false, extraSignal } = {}) {
102
+ const requestHeaders = {
103
+ accept: 'application/vnd.github+json',
104
+ 'x-github-api-version': '2022-11-28',
105
+ 'user-agent': USER_AGENT,
106
+ ...headers,
107
+ }
108
+ if (token !== '') requestHeaders.authorization = `Bearer ${token}`
109
+ if (body !== undefined) requestHeaders['content-type'] = 'application/json'
110
+ const signals = [AbortSignal.timeout(timeoutMs)]
111
+ for (const candidate of [extraSignal]) {
112
+ if (candidate !== undefined) signals.push(candidate)
113
+ }
114
+ const combined = signals.length === 1 ? signals[0] : AbortSignal.any(signals)
115
+ let response
116
+ try {
117
+ response = await fetchImpl(url, {
118
+ method,
119
+ headers: requestHeaders,
120
+ body: body === undefined ? undefined : JSON.stringify(body),
121
+ signal: combined,
122
+ })
123
+ } catch (error) {
124
+ if (combined.aborted) {
125
+ throw new GitHubError('GitHub request was aborted or timed out', { code: 'timeout', retryable: true })
126
+ }
127
+ throw new GitHubError(`GitHub request failed: ${error instanceof Error ? error.message : String(error)}`, {
128
+ code: 'network',
129
+ retryable: true,
130
+ })
131
+ }
132
+ const raw = await response.text()
133
+ let parsed = null
134
+ if (raw.trim() !== '') {
135
+ try {
136
+ parsed = JSON.parse(raw)
137
+ } catch {
138
+ parsed = raw
139
+ }
140
+ }
141
+ if (response.ok) return { status: response.status, body: parsed, headers: response.headers }
142
+ if (response.status === 404 && allow404) return null
143
+ if (response.status === 409 || response.status === 412) {
144
+ throw new GitHubError(errorMessageFromBody(parsed, response.status), {
145
+ code: 'conflict',
146
+ status: response.status,
147
+ retryable: true,
148
+ body: parsed,
149
+ })
150
+ }
151
+ const retryable = response.status === 429 || response.status >= 500
152
+ const code = response.status === 401 || response.status === 403
153
+ ? 'auth'
154
+ : response.status === 404
155
+ ? 'not-found'
156
+ : retryable
157
+ ? 'server'
158
+ : 'github-error'
159
+ throw new GitHubError(errorMessageFromBody(parsed, response.status), {
160
+ code,
161
+ status: response.status,
162
+ retryable,
163
+ body: parsed,
164
+ })
165
+ }
166
+
167
+ async function readGist(gistId, fileName = 'dsh-plugins.json', { extraSignal } = {}) {
168
+ if (typeof gistId !== 'string' || gistId === '') throw new GitHubError('A Gist id is required', { code: 'invalid-gist' })
169
+ const name = gistFileName(fileName)
170
+ const response = await requestUrl(`${base}/gists/${encodeURIComponent(gistId)}`, {
171
+ allow404: true,
172
+ extraSignal,
173
+ })
174
+ if (response === null) {
175
+ throw new GitHubError(`Gist ${gistId} was not found or is not accessible with this token`, { code: 'not-found' })
176
+ }
177
+ const gist = response.body
178
+ const summary = gistSummary(gist)
179
+ const file = gist !== null && typeof gist === 'object' && gist.files !== null && typeof gist.files === 'object'
180
+ ? gist.files[name]
181
+ : undefined
182
+ if (file === undefined || file === null) {
183
+ return {
184
+ ...summary,
185
+ exists: false,
186
+ fileName: name,
187
+ manifest: emptyManifest(),
188
+ etag: response.headers.get('etag'),
189
+ }
190
+ }
191
+ let text = typeof file.content === 'string' ? file.content : ''
192
+ if (file.truncated === true || (text === '' && typeof file.raw_url === 'string')) {
193
+ if (typeof file.raw_url !== 'string' || file.raw_url === '') {
194
+ throw new GitHubError(`Gist file ${name} is truncated and has no raw URL`, { code: 'invalid-response' })
195
+ }
196
+ const raw = await requestUrl(file.raw_url, {
197
+ headers: { accept: 'text/plain' },
198
+ extraSignal,
199
+ })
200
+ text = typeof raw.body === 'string'
201
+ ? raw.body
202
+ : raw.body === null || raw.body === undefined ? '' : JSON.stringify(raw.body)
203
+ }
204
+ if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) {
205
+ throw new GitHubError(`Gist manifest ${name} is larger than 1 MB`, { code: 'manifest-too-large' })
206
+ }
207
+ let parsed
208
+ try {
209
+ parsed = JSON.parse(text)
210
+ } catch {
211
+ throw new GitHubError(`Gist file ${name} is not valid JSON`, { code: 'invalid-manifest' })
212
+ }
213
+ const manifest = normalizeManifest(parsed, { source: `gist ${gistId}/${name}`, requireShape: true })
214
+ return {
215
+ ...summary,
216
+ exists: true,
217
+ fileName: name,
218
+ manifest,
219
+ etag: response.headers.get('etag'),
220
+ }
221
+ }
222
+
223
+ async function listGists({ extraSignal } = {}) {
224
+ const response = await requestUrl(`${base}/gists?per_page=100`, { extraSignal })
225
+ if (!Array.isArray(response.body)) {
226
+ throw new GitHubError('GitHub did not return a Gist list', { code: 'invalid-response' })
227
+ }
228
+ return response.body.map(gistSummary)
229
+ }
230
+
231
+ async function createGist({ description = 'DSH plugin inventory', fileName = 'dsh-plugins.json', manifest, extraSignal } = {}) {
232
+ if (token === '') {
233
+ throw new GitHubError('A GitHub token is required to create a Gist', { code: 'no-token' })
234
+ }
235
+ const name = gistFileName(fileName)
236
+ const text = `${JSON.stringify(manifest, null, 2)}\n`
237
+ if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) {
238
+ throw new GitHubError('Manifest is larger than 1 MB', { code: 'manifest-too-large' })
239
+ }
240
+ const response = await requestUrl(`${base}/gists`, {
241
+ method: 'POST',
242
+ body: {
243
+ description,
244
+ public: false,
245
+ files: { [name]: { content: text } },
246
+ },
247
+ extraSignal,
248
+ })
249
+ return { ...gistSummary(response.body), fileName: name }
250
+ }
251
+
252
+ /**
253
+ * Update a Gist's manifest file.
254
+ *
255
+ * GitHub rejects conditional headers on this endpoint ("Conditional request
256
+ * headers are not allowed in unsafe requests unless supported by the
257
+ * endpoint"), so conflict detection uses the read-then-merge retry in the
258
+ * caller instead of If-Match. `expectedVersion` is only used to detect that
259
+ * someone else changed the Gist between the caller's read and this write.
260
+ */
261
+ async function updateGist(gistId, {
262
+ description = 'DSH plugin inventory',
263
+ fileName = 'dsh-plugins.json',
264
+ manifest,
265
+ expectedVersion = null,
266
+ extraSignal,
267
+ } = {}) {
268
+ if (token === '') {
269
+ throw new GitHubError('A GitHub token is required to update a Gist', { code: 'no-token' })
270
+ }
271
+ const name = gistFileName(fileName)
272
+ const text = `${JSON.stringify(manifest, null, 2)}\n`
273
+ if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) {
274
+ throw new GitHubError('Manifest is larger than 1 MB', { code: 'manifest-too-large' })
275
+ }
276
+ if (expectedVersion !== null) {
277
+ const current = await requestUrl(`${base}/gists/${encodeURIComponent(gistId)}`, { extraSignal })
278
+ const currentVersion = gistVersion(current.body)
279
+ if (currentVersion !== null && currentVersion !== expectedVersion) {
280
+ throw new GitHubError('The Gist changed after it was read; retry the update', {
281
+ code: 'conflict',
282
+ status: 412,
283
+ retryable: true,
284
+ })
285
+ }
286
+ }
287
+ const response = await requestUrl(`${base}/gists/${encodeURIComponent(gistId)}`, {
288
+ method: 'PATCH',
289
+ body: {
290
+ description,
291
+ files: { [name]: { content: text } },
292
+ },
293
+ extraSignal,
294
+ })
295
+ return { ...gistSummary(response.body), fileName: name }
296
+ }
297
+
298
+ return {
299
+ apiBaseUrl: base,
300
+ readGist,
301
+ listGists,
302
+ createGist,
303
+ updateGist,
304
+ }
305
+ }
package/lib/http.js ADDED
@@ -0,0 +1,285 @@
1
+ import {
2
+ clearCredential,
3
+ ConfigError,
4
+ describeCredential,
5
+ readConfigSnapshot,
6
+ setCredential,
7
+ writeConfigValues,
8
+ } from './config.js'
9
+
10
+ const API_PREFIX = '/dsh-plugin-syncup/api/v2'
11
+ const MAX_BODY_BYTES = 64 * 1024
12
+
13
+ function sendJson(response, status, payload) {
14
+ const body = `${JSON.stringify(payload)}\n`
15
+ response.writeHead(status, {
16
+ 'content-type': 'application/json; charset=utf-8',
17
+ 'cache-control': 'no-store',
18
+ 'content-length': Buffer.byteLength(body),
19
+ })
20
+ response.end(body)
21
+ }
22
+
23
+ function sendError(response, status, code, message) {
24
+ sendJson(response, status, { ok: false, error: { code, message } })
25
+ }
26
+
27
+ function sameOrigin(request) {
28
+ const origin = request.headers.origin
29
+ if (typeof origin !== 'string' || origin === '') return true
30
+ try {
31
+ return new URL(origin).host === request.headers.host
32
+ } catch {
33
+ return false
34
+ }
35
+ }
36
+
37
+ async function readJson(request) {
38
+ const length = Number(request.headers['content-length'] ?? 0)
39
+ if (Number.isFinite(length) && length > MAX_BODY_BYTES) {
40
+ const error = new Error('Request body is too large')
41
+ error.status = 413
42
+ throw error
43
+ }
44
+ let total = 0
45
+ const chunks = []
46
+ for await (const chunk of request) {
47
+ total += chunk.length
48
+ if (total > MAX_BODY_BYTES) {
49
+ const error = new Error('Request body is too large')
50
+ error.status = 413
51
+ throw error
52
+ }
53
+ chunks.push(chunk)
54
+ }
55
+ if (total === 0) return {}
56
+ try {
57
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
58
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
59
+ throw new Error('JSON body must be an object')
60
+ }
61
+ return parsed
62
+ } catch (error) {
63
+ const failure = new Error(`Invalid JSON body: ${error instanceof Error ? error.message : String(error)}`)
64
+ failure.status = 400
65
+ throw failure
66
+ }
67
+ }
68
+
69
+ function isRecord(value) {
70
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
71
+ }
72
+
73
+ function methodGuard(request, response, allowed) {
74
+ if (allowed.includes(request.method)) return true
75
+ response.writeHead(405, { allow: allowed.join(', ') })
76
+ response.end()
77
+ return false
78
+ }
79
+
80
+ export function registerApiRoutes({ ctx, runtime, config }) {
81
+ const routes = [
82
+ {
83
+ name: 'status',
84
+ methods: ['GET'],
85
+ path: `${API_PREFIX}/status`,
86
+ handle: async (_request, response) => {
87
+ const status = await runtime.status()
88
+ const credential = await describeCredential(ctx)
89
+ if (credential.available) {
90
+ status.token = {
91
+ configured: credential.configured,
92
+ source: credential.source,
93
+ writable: credential.writable,
94
+ }
95
+ }
96
+ const configSnapshot = readConfigSnapshot(ctx, config)
97
+ sendJson(response, 200, {
98
+ ...status,
99
+ configSnapshot,
100
+ })
101
+ },
102
+ },
103
+ {
104
+ name: 'config',
105
+ methods: ['GET', 'POST'],
106
+ path: `${API_PREFIX}/config`,
107
+ handle: async (request, response) => {
108
+ if (request.method === 'GET') {
109
+ sendJson(response, 200, {
110
+ ok: true,
111
+ config: readConfigSnapshot(ctx, config),
112
+ credential: await describeCredential(ctx),
113
+ })
114
+ return
115
+ }
116
+ const body = await readJson(request)
117
+ if (!isRecord(body.values)) {
118
+ sendError(response, 400, 'invalid-request', 'values must be an object')
119
+ return
120
+ }
121
+ if (body.clearToken === true) {
122
+ await clearCredential(ctx)
123
+ } else if (typeof body.token === 'string' && body.token.trim() !== '') {
124
+ await setCredential(ctx, body.token)
125
+ }
126
+ const values = await writeConfigValues(ctx, body.values, config)
127
+ sendJson(response, 200, {
128
+ ok: true,
129
+ values,
130
+ credential: await describeCredential(ctx),
131
+ })
132
+ },
133
+ },
134
+ {
135
+ name: 'jobs',
136
+ methods: ['GET'],
137
+ path: `${API_PREFIX}/jobs`,
138
+ handle: async (request, response) => {
139
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
140
+ const jobId = url.searchParams.get('id')
141
+ if (typeof jobId === 'string' && jobId !== '') {
142
+ const job = runtime.job(jobId)
143
+ if (job === null) {
144
+ sendError(response, 404, 'job-not-found', `No job with id ${jobId}`)
145
+ return
146
+ }
147
+ sendJson(response, 200, { ok: true, job })
148
+ return
149
+ }
150
+ const kind = url.searchParams.get('kind')
151
+ sendJson(response, 200, {
152
+ ok: true,
153
+ latest: runtime.latestJob(kind === null ? undefined : kind),
154
+ jobs: runtime.jobs(kind === null ? undefined : kind),
155
+ })
156
+ },
157
+ },
158
+ {
159
+ name: 'latest',
160
+ methods: ['GET', 'POST'],
161
+ path: `${API_PREFIX}/latest`,
162
+ handle: async (request, response) => {
163
+ if (request.method === 'GET') {
164
+ // `cached=1` restores the last result without re-querying the registries,
165
+ // so a page that remounts mid-work does not start a second check.
166
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
167
+ if (url.searchParams.get('cached') === '1') {
168
+ const cached = runtime.cachedLatest()
169
+ sendJson(response, 200, cached === null
170
+ ? { ok: true, cached: false, entries: [], counts: { entries: 0, outdated: 0, current: 0, unsupported: 0, failed: 0 } }
171
+ : { ok: true, cached: true, ...cached })
172
+ return
173
+ }
174
+ const result = await runtime.checkLatest()
175
+ sendJson(response, 200, result)
176
+ return
177
+ }
178
+ const body = await readJson(request)
179
+ if (!Array.isArray(body.names)) {
180
+ sendError(response, 400, 'invalid-request', 'names must be an array of bundle names')
181
+ return
182
+ }
183
+ const result = await runtime.applyLatest(body.names, {
184
+ channel: typeof body.channel === 'string' ? body.channel : null,
185
+ channels: isRecord(body.channels) ? body.channels : {},
186
+ })
187
+ sendJson(response, 200, result)
188
+ },
189
+ },
190
+ {
191
+ name: 'preview',
192
+ methods: ['POST'],
193
+ path: `${API_PREFIX}/preview`,
194
+ handle: async (_request, response) => {
195
+ const preview = await runtime.preview()
196
+ sendJson(response, 200, preview)
197
+ },
198
+ },
199
+ {
200
+ name: 'apply',
201
+ methods: ['POST'],
202
+ path: `${API_PREFIX}/apply`,
203
+ handle: async (request, response) => {
204
+ const body = await readJson(request)
205
+ if (typeof body.previewId !== 'string' || body.previewId === '') {
206
+ sendError(response, 400, 'invalid-request', 'previewId is required')
207
+ return
208
+ }
209
+ if (!isRecord(body.selections)) {
210
+ sendError(response, 400, 'invalid-request', 'selections must be an object')
211
+ return
212
+ }
213
+ const result = await runtime.apply(body.previewId, body.selections)
214
+ sendJson(response, 200, result)
215
+ },
216
+ },
217
+ {
218
+ name: 'push',
219
+ methods: ['POST'],
220
+ path: `${API_PREFIX}/push`,
221
+ handle: async (request, response) => {
222
+ const body = await readJson(request)
223
+ if (body.confirm !== true) {
224
+ sendError(response, 400, 'confirm-required', 'Set confirm=true to update the remote manifest')
225
+ return
226
+ }
227
+ const result = await runtime.push({
228
+ planId: typeof body.planId === 'string' && body.planId !== '' ? body.planId : null,
229
+ selections: isRecord(body.selections) ? body.selections : {},
230
+ })
231
+ sendJson(response, 200, result)
232
+ },
233
+ },
234
+ {
235
+ name: 'plan-push',
236
+ methods: ['POST'],
237
+ path: `${API_PREFIX}/push/plan`,
238
+ handle: async (_request, response) => {
239
+ const result = await runtime.planPush()
240
+ sendJson(response, 200, result)
241
+ },
242
+ },
243
+ ]
244
+
245
+ const mount = (hostCtx) => {
246
+ hostCtx.effect(() => {
247
+ const disposers = []
248
+ for (const route of routes) {
249
+ disposers.push(hostCtx.webServer.register({
250
+ kind: 'exact',
251
+ path: route.path,
252
+ handler: async (request, response) => {
253
+ if (!sameOrigin(request)) {
254
+ sendError(response, 403, 'cross-origin', 'Cross-origin requests are not allowed')
255
+ return
256
+ }
257
+ if (!methodGuard(request, response, route.methods)) return
258
+ try {
259
+ await route.handle(request, response)
260
+ } catch (error) {
261
+ if (error instanceof ConfigError) {
262
+ sendError(response, 400, error.code, error.message)
263
+ return
264
+ }
265
+ const status = typeof error?.status === 'number' ? error.status : 500
266
+ const code = typeof error?.code === 'string' ? error.code : 'operation-failed'
267
+ const message = error instanceof Error ? error.message : String(error)
268
+ sendError(response, status, code, message)
269
+ }
270
+ },
271
+ }))
272
+ }
273
+ return () => {
274
+ for (const dispose of disposers) dispose()
275
+ }
276
+ }, 'plugin-sync: API routes')
277
+ }
278
+
279
+ const existing = ctx.get('webServer')
280
+ if (existing !== undefined && existing !== null) {
281
+ mount({ webServer: existing, effect: ctx.effect.bind(ctx) })
282
+ } else {
283
+ ctx.inject(['webServer'], mount)
284
+ }
285
+ }
package/lib/jobs.js ADDED
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ const MAX_JOBS = 10
4
+ const MAX_LINES = 400
5
+
6
+ function nowIso(now) {
7
+ return now().toISOString()
8
+ }
9
+
10
+ function clampText(value) {
11
+ const text = typeof value === 'string' ? value : String(value ?? '')
12
+ return text.length > 2000 ? `${text.slice(0, 2000)}…` : text
13
+ }
14
+
15
+ export function createJobStore({ now = () => new Date(), keep = MAX_JOBS } = {}) {
16
+ const jobs = new Map()
17
+
18
+ function trim() {
19
+ while (jobs.size > keep) {
20
+ const oldest = jobs.keys().next().value
21
+ if (oldest === undefined) return
22
+ jobs.delete(oldest)
23
+ }
24
+ }
25
+
26
+ function start(kind, meta = {}) {
27
+ const job = {
28
+ id: randomUUID(),
29
+ kind,
30
+ status: 'running',
31
+ startedAt: nowIso(now),
32
+ finishedAt: null,
33
+ progress: { done: 0, total: 0 },
34
+ lines: [],
35
+ result: null,
36
+ error: null,
37
+ ...meta,
38
+ }
39
+ jobs.set(job.id, job)
40
+ trim()
41
+ return job
42
+ }
43
+
44
+ function append(jobId, level, message) {
45
+ const job = jobs.get(jobId)
46
+ if (job === undefined) return
47
+ job.lines.push({ at: nowIso(now), level, message: clampText(message) })
48
+ if (job.lines.length > MAX_LINES) job.lines.splice(0, job.lines.length - MAX_LINES)
49
+ }
50
+
51
+ function progress(jobId, done, total) {
52
+ const job = jobs.get(jobId)
53
+ if (job === undefined) return
54
+ job.progress = { done, total }
55
+ }
56
+
57
+ function finish(jobId, { result = null, error = null } = {}) {
58
+ const job = jobs.get(jobId)
59
+ if (job === undefined) return null
60
+ job.status = error === null ? 'done' : 'failed'
61
+ job.finishedAt = nowIso(now)
62
+ job.result = result
63
+ job.error = error === null ? null : clampText(error)
64
+ if (job.progress.total > 0) job.progress = { ...job.progress, done: job.progress.total }
65
+ return snapshot(job)
66
+ }
67
+
68
+ function snapshot(job) {
69
+ if (job === undefined || job === null) return null
70
+ return {
71
+ id: job.id,
72
+ kind: job.kind,
73
+ status: job.status,
74
+ startedAt: job.startedAt,
75
+ finishedAt: job.finishedAt,
76
+ progress: job.progress,
77
+ lines: job.lines,
78
+ result: job.result,
79
+ error: job.error,
80
+ ...(job.previewId === undefined ? {} : { previewId: job.previewId }),
81
+ }
82
+ }
83
+
84
+ function get(jobId) {
85
+ return snapshot(jobs.get(jobId))
86
+ }
87
+
88
+ function latest(kind) {
89
+ let found = null
90
+ for (const job of jobs.values()) {
91
+ if (kind !== undefined && job.kind !== kind) continue
92
+ found = job
93
+ }
94
+ return snapshot(found)
95
+ }
96
+
97
+ function list(kind) {
98
+ const items = []
99
+ for (const job of jobs.values()) {
100
+ if (kind !== undefined && job.kind !== kind) continue
101
+ items.push(snapshot(job))
102
+ }
103
+ return items.slice(-keep).reverse()
104
+ }
105
+
106
+ return { start, append, progress, finish, get, latest, list }
107
+ }