@chatcode/cco-market 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/src/themes.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Theme lifecycle: classifying installed packages as themes (by the
3
+ * registry's theme category), live-toggling bundle-layer entries through
4
+ * the loader, and keeping exactly one theme active with the choice
5
+ * persisted across restarts.
6
+ */
7
+
8
+ import { loadRegistry, pluginCategories } from './registry.ts'
9
+ import { hotMount, hotUnmount, listHotMounts, writeDisabled } from './hot.ts'
10
+ import { logEvent } from './log.ts'
11
+ import { profileDir, readInstalled } from './profile.ts'
12
+ import { repoOf } from './sources.ts'
13
+
14
+ /** The slice of a cordis loader entry the market needs for live enable/disable. */
15
+ export interface LoaderEntry {
16
+ options: { id?: string; name?: string; disabled?: boolean | null }
17
+ fiber?: unknown
18
+ update(options: { disabled: boolean | null }, create?: boolean, force?: boolean): Promise<void>
19
+ }
20
+
21
+ /** The host surface the theme manager needs (loader entries + hot-mount context). */
22
+ export interface ThemeHost {
23
+ loader: { entries(): Iterable<LoaderEntry> }
24
+ plugin(plugin: unknown, config: unknown): { await(): Promise<unknown>; dispose(): Promise<unknown> | void }
25
+ }
26
+
27
+ /** Manages theme exclusivity for one profile. */
28
+ export interface ThemeManager {
29
+ installedThemeNames(): Promise<Set<string>>
30
+ setEntryDisabled(name: string, disabledFlag: boolean): Promise<boolean>
31
+ activateTheme(name: string): Promise<boolean>
32
+ }
33
+
34
+ /**
35
+ * Create the theme manager. `disabledThemes` is the live, shared set of
36
+ * themes the user switched off — the caller owns reading it at boot and
37
+ * replaying it; the manager mutates and persists it on switches.
38
+ */
39
+ export function createThemeManager(
40
+ host: ThemeHost,
41
+ profile: string,
42
+ disabledThemes: Set<string>,
43
+ explicitDir?: string,
44
+ ): ThemeManager {
45
+ const activeProfileDir = profileDir(profile, explicitDir)
46
+ /** Installed package names classified as themes by the registry's theme category. */
47
+ async function installedThemeNames(): Promise<Set<string>> {
48
+ const names = new Set<string>()
49
+ try {
50
+ const registry = await loadRegistry()
51
+ const themeEntries = registry.plugins.filter(p => pluginCategories(p).includes('theme'))
52
+ const themeNames = new Set(themeEntries.map(p => p.name))
53
+ const themeRepos = new Set(
54
+ themeEntries.map(p => repoOf(p.url)).filter((r): r is string => r !== null).map(r => r.toLowerCase()),
55
+ )
56
+ for (const [name, spec] of Object.entries(readInstalled(profile, activeProfileDir))) {
57
+ if (themeNames.has(name)) {
58
+ names.add(name)
59
+ continue
60
+ }
61
+ const match = /github:([^#\s]+)/.exec(String(spec).toLowerCase())
62
+ if (match !== null && themeRepos.has(match[1])) names.add(name)
63
+ }
64
+ } catch { /* registry unavailable — nothing classifies as a theme */ }
65
+ return names
66
+ }
67
+
68
+ /**
69
+ * Live-toggle a bundle-layer plugin through its loader entry. Bundle trees
70
+ * are in-memory (write is a no-op), so this never touches any file — the
71
+ * market persists the choice itself and replays it at boot.
72
+ * @returns true when a matching live entry was found and updated.
73
+ */
74
+ async function setEntryDisabled(name: string, disabledFlag: boolean): Promise<boolean> {
75
+ let found = false
76
+ for (const entry of host.loader.entries()) {
77
+ if (entry.options.name !== name) continue
78
+ // A disable can land while the entry's init is still in flight: the
79
+ // options flip but the finishing init brings the fiber up anyway, and a
80
+ // plain re-update no-ops on the empty diff. Force the update and verify
81
+ // the live state, retrying until reality matches the flag.
82
+ for (let attempt = 0; attempt < 3; attempt++) {
83
+ try {
84
+ await entry.update({ disabled: disabledFlag ? true : null }, false, true)
85
+ found = true
86
+ } catch (error) {
87
+ logEvent('warn', 'toggle', `${name}: entry update failed — ${error instanceof Error ? error.message : String(error)}`)
88
+ break
89
+ }
90
+ const live = entry.fiber !== undefined
91
+ if (live !== disabledFlag) break
92
+ await new Promise(resolvePromise => setTimeout(resolvePromise, 200))
93
+ }
94
+ logEvent('info', 'toggle',
95
+ `${name} -> ${disabledFlag ? 'off' : 'on'}: fiber=${String(entry.fiber !== undefined)}`)
96
+ }
97
+ if (!found) logEvent('info', 'toggle', `${name}: no loader entry matched`)
98
+ return found
99
+ }
100
+
101
+ /**
102
+ * Make `name` the one active theme: deactivate every other installed theme
103
+ * (market hot mounts unmount; bundle-layer entries live-disable) and bring
104
+ * it up. The choice persists in state.json and is replayed at boot.
105
+ */
106
+ async function activateTheme(name: string): Promise<boolean> {
107
+ const themes = await installedThemeNames()
108
+ for (const other of themes) {
109
+ if (other === name) continue
110
+ if (listHotMounts().includes(other)) {
111
+ await hotUnmount(other)
112
+ disabledThemes.add(other)
113
+ } else if (await setEntryDisabled(other, true)) {
114
+ disabledThemes.add(other)
115
+ }
116
+ }
117
+ disabledThemes.delete(name)
118
+ writeDisabled(activeProfileDir, disabledThemes)
119
+ if (listHotMounts().includes(name)) return true
120
+ if (await setEntryDisabled(name, false)) return true
121
+ return (await hotMount(host, activeProfileDir, name)).ok
122
+ }
123
+
124
+ return { installedThemeNames, setEntryDisabled, activateTheme }
125
+ }
package/src/trial.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Trial validation for composition changes — issue #98 (phase 3), the
3
+ * "trial boot" half of #19 reduced to what is safe and offline: before any
4
+ * bundle-order or preset change is written to the profile, replay the
5
+ * composition with the CANDIDATE order using the same entry-list machinery
6
+ * the real boot uses (src/check.ts's buildBundleLayers + composeLayers), and
7
+ * refuse the change when the composed tree would fail to boot (duplicate
8
+ * loader entry ids, unresolvable bundle layers, unparseable patches).
9
+ *
10
+ * No process, no network, no writes to the profile: the real profile is only
11
+ * read; if the candidate is bad, the failure is reported and nothing is
12
+ * applied (the caller then skips the write-back entirely).
13
+ *
14
+ * Issue #125 review: the CURRENT composition is replayed alongside the
15
+ * candidate, so the response also carries a current-vs-candidate diff
16
+ * (overrides / orphans / duplicates the reorder introduces) — not just
17
+ * whether it boots.
18
+ *
19
+ * Bundle resolution is deliberately SHARED with the check report
20
+ * (check.ts's buildBundleLayers): the dsh installation anchor first, then
21
+ * Node's module search from the profile (workspace-root hoisting) — so the
22
+ * trial can never disagree with the diagnostics about what a bundle is or
23
+ * where it lives, and official bundles resolve even when they are only
24
+ * hoisted to the workspace root.
25
+ */
26
+
27
+ import { existsSync, readFileSync } from 'node:fs'
28
+ import { join } from 'node:path'
29
+ import {
30
+ buildBundleLayers, composeLayers, findDshInstallDir, parsePatchFile,
31
+ type DuplicateId, type LayerInput, type OrphanRow, type OverrideRow,
32
+ } from './check.ts'
33
+ import { resolveDshHome } from './home-paths.ts'
34
+ import { mergeOrder, readBundleStack } from './order.ts'
35
+
36
+ export interface TrialIssue {
37
+ layer: string
38
+ message: string
39
+ }
40
+
41
+ /** Current-vs-candidate composition diff (issue #125 review). */
42
+ export interface TrialDiff {
43
+ /** Override relationships introduced by the candidate (not present in the current composition). */
44
+ overrides: OverrideRow[]
45
+ /** Orphan rows introduced by the candidate. */
46
+ orphans: OrphanRow[]
47
+ /** Duplicate loader entry ids introduced by the candidate. */
48
+ duplicates: DuplicateId[]
49
+ }
50
+
51
+ export interface TrialResult {
52
+ ok: boolean
53
+ errors: TrialIssue[]
54
+ warnings: TrialIssue[]
55
+ duplicates: DuplicateId[]
56
+ /** The composed loader rows under the candidate order. */
57
+ rows: { id: string; layer: string }[]
58
+ /** What the reorder would change vs the current composition (issue #125 review). */
59
+ diff: TrialDiff
60
+ }
61
+
62
+ /**
63
+ * Replay the profile composition with `newCommunityOrder` (the candidate
64
+ * community-bundle order; official bundles keep their exact positions) and
65
+ * report anything that would break the boot. Pure read.
66
+ */
67
+ export function trialValidate(
68
+ profileDir: string,
69
+ newCommunityOrder: string[],
70
+ options: { dshInstallDir?: string | null; homeDir?: string } = {},
71
+ ): TrialResult {
72
+ const errors: TrialIssue[] = []
73
+ const warnings: TrialIssue[] = []
74
+ const current = readBundleStack(profileDir)
75
+ // Same merge the apply path uses (order.ts mergeOrder): the trial replays
76
+ // EXACTLY the stack a successful apply would write.
77
+ const merged = mergeOrder(current.bundles, newCommunityOrder)
78
+ if (!merged.ok) {
79
+ return {
80
+ ok: false, errors: [{ layer: '(order)', message: merged.error }], warnings,
81
+ duplicates: [], rows: [],
82
+ diff: { overrides: [], orphans: [], duplicates: [] },
83
+ }
84
+ }
85
+ const candidate = merged.bundles
86
+
87
+ // Shared bundle-layer build — identical resolution to analyzeProfile
88
+ // (dsh install anchor first, then Node module search from the profile).
89
+ let specs: Record<string, string> = {}
90
+ try {
91
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as {
92
+ dependencies?: Record<string, string>
93
+ }
94
+ specs = manifest.dependencies ?? {}
95
+ } catch { /* specs stay empty — sources read as '(not a direct dependency)' */ }
96
+ const dshInstall = options.dshInstallDir !== undefined ? options.dshInstallDir : findDshInstallDir()
97
+
98
+ // Same user/home patch layers as the boot (and the check report) — shared
99
+ // by BOTH compositions.
100
+ const patchLayers: LayerInput[] = []
101
+ const userPatchPath = join(profileDir, 'cordis.patch.yml')
102
+ if (existsSync(userPatchPath)) {
103
+ const patches = parsePatchFile(userPatchPath)
104
+ patchLayers.push({ label: 'user-patch', kind: 'user', patches: patches ?? [], parseError: null })
105
+ if (patches === null) errors.push({ layer: 'user-patch', message: 'cordis.patch.yml is not a valid entry list / cordis.patch.yml 不是合法的条目列表' })
106
+ }
107
+ const home = resolveDshHome(options.homeDir)
108
+ const homePatchPath = join(home, 'cordis.patch.yml')
109
+ if (existsSync(homePatchPath)) {
110
+ const patches = parsePatchFile(homePatchPath)
111
+ patchLayers.push({ label: 'home-patch', kind: 'home', patches: patches ?? [], parseError: null })
112
+ if (patches === null) errors.push({ layer: 'home-patch', message: 'home cordis.patch.yml is not a valid entry list / 全局 cordis.patch.yml 不是合法的条目列表' })
113
+ }
114
+
115
+ /** Compose the loader tree for one bundle order (current or candidate). */
116
+ const compose = (bundleOrder: string[]): { built: ReturnType<typeof buildBundleLayers>; composed: ReturnType<typeof composeLayers> } => {
117
+ const built = buildBundleLayers(profileDir, bundleOrder, specs, dshInstall)
118
+ return { built, composed: composeLayers([...built.layers, ...patchLayers]) }
119
+ }
120
+
121
+ const currentState = compose(current.bundles)
122
+ const candidateState = compose(candidate)
123
+ const composed = candidateState.composed
124
+
125
+ for (const bundle of candidateState.built.bundles) {
126
+ if (bundle.error !== null) errors.push({ layer: bundle.name, message: bundle.error })
127
+ if (bundle.parseError !== null) errors.push({ layer: bundle.name, message: bundle.parseError })
128
+ }
129
+ for (const dup of composed.duplicates) {
130
+ errors.push({ layer: dup.layers.join(' / '), message: `duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows) / 重复的 loader 条目 id ${JSON.stringify(dup.id)}` })
131
+ }
132
+ for (const orphan of composed.orphans) {
133
+ warnings.push({ layer: orphan.layer, message: `${orphan.id}: ${orphan.reason}` })
134
+ }
135
+
136
+ // Issue #125 review: what does the reorder CHANGE? Compare the candidate
137
+ // composition against the current one — only candidate-introduced
138
+ // overrides / orphans / duplicates are reported.
139
+ const currentDupIds = new Set(currentState.composed.duplicates.map(d => d.id))
140
+ const sameOverride = (a: OverrideRow, b: OverrideRow): boolean =>
141
+ a.id === b.id && a.layer === b.layer && a.overriddenLayers.join('\u0000') === b.overriddenLayers.join('\u0000')
142
+ const diff: TrialDiff = {
143
+ overrides: composed.overrides.filter(o => !currentState.composed.overrides.some(c => sameOverride(o, c))),
144
+ orphans: composed.orphans.filter(o => !currentState.composed.orphans.some(c => c.id === o.id && c.layer === o.layer)),
145
+ duplicates: composed.duplicates.filter(d => !currentDupIds.has(d.id)),
146
+ }
147
+
148
+ return {
149
+ ok: errors.length === 0,
150
+ errors,
151
+ warnings,
152
+ duplicates: composed.duplicates,
153
+ rows: composed.rows.map(row => ({ id: row.id, layer: row.layer })),
154
+ diff,
155
+ }
156
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Stable, versioned contract for plugin-owned update surfaces.
3
+ *
4
+ * The market UI has richer internal response shapes that evolve with its UI.
5
+ * Third-party plugins must not depend on those shapes, so this module owns the
6
+ * small JSON envelope exposed under `/dsh-market/api/v1`.
7
+ */
8
+
9
+ import type { InstallProgress } from './dsh-cli.ts'
10
+
11
+ export const UPDATE_API_V1_SCHEMA = 'dsh-market/update-api/v1' as const
12
+ export const MAX_UPDATE_OPERATIONS_V1 = 50
13
+
14
+ export type UpdateOperationState =
15
+ | 'queued'
16
+ | 'running'
17
+ | 'succeeded'
18
+ | 'failed'
19
+ | 'cancelled'
20
+ | 'rolled-back'
21
+
22
+ export interface UpdateFailureV1 {
23
+ code: string
24
+ message: string
25
+ retryable: boolean
26
+ }
27
+
28
+ export interface UpdateOperationV1 {
29
+ schema: typeof UPDATE_API_V1_SCHEMA
30
+ operationId: string
31
+ kind: 'update'
32
+ packageName: string
33
+ state: UpdateOperationState
34
+ createdAt: number
35
+ startedAt: number | null
36
+ finishedAt: number | null
37
+ beforeVersion: string | null
38
+ installedVersion: string | null
39
+ progress: {
40
+ phase: string | null
41
+ done: number
42
+ total: number | null
43
+ percent: number | null
44
+ currentPackage: string | null
45
+ detail: string | null
46
+ downloaded: number | null
47
+ size: number | null
48
+ }
49
+ outcome: {
50
+ refreshRequired: boolean
51
+ restartRequired: boolean
52
+ rollback: {
53
+ available: boolean
54
+ state: 'unavailable' | 'available' | 'running' | 'succeeded' | 'failed'
55
+ detail: string | null
56
+ }
57
+ }
58
+ failure: UpdateFailureV1 | null
59
+ }
60
+
61
+ interface StoredOperation extends UpdateOperationV1 {
62
+ legacyRollbackId: string | null
63
+ }
64
+
65
+ function record(value: unknown): Record<string, unknown> | null {
66
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
67
+ ? value as Record<string, unknown>
68
+ : null
69
+ }
70
+
71
+ function text(value: unknown): string | null {
72
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
73
+ }
74
+
75
+ function bool(value: unknown): boolean {
76
+ return value === true
77
+ }
78
+
79
+ function failureCode(status: number, body: Record<string, unknown>): string {
80
+ const explicit = text(body.failureCode)
81
+ if (explicit === 'DOWNGRADE_DETECTED' || explicit === 'RESOLVED_VERSION_MISMATCH') return explicit
82
+ if (body.agentsBusy === true) return 'AGENTS_RUNNING'
83
+ if (body.busy === true || status === 409) return 'OPERATION_BUSY'
84
+ if (body.stale === true && body.staleReason === 'release-age') return 'RELEASE_TOO_FRESH'
85
+ if (body.stale === true) return 'VERSION_UNCHANGED'
86
+ if (body.timedOut === true) return 'UPDATE_TIMEOUT'
87
+ if (status === 403) return 'UPDATE_FORBIDDEN'
88
+ if (status === 400) return 'UPDATE_REJECTED'
89
+ return 'UPDATE_FAILED'
90
+ }
91
+
92
+ function failureOf(status: number, body: Record<string, unknown>): UpdateFailureV1 {
93
+ const code = failureCode(status, body)
94
+ const message = text(body.error)
95
+ ?? text(body.stderr)
96
+ ?? text(body.stdout)
97
+ ?? `update failed with HTTP ${String(status)}`
98
+ return {
99
+ code,
100
+ message: message.slice(-1200),
101
+ retryable: code === 'AGENTS_RUNNING'
102
+ || code === 'OPERATION_BUSY'
103
+ || code === 'RELEASE_TOO_FRESH'
104
+ || code === 'VERSION_UNCHANGED'
105
+ || code === 'RESOLVED_VERSION_MISMATCH'
106
+ || code === 'UPDATE_TIMEOUT',
107
+ }
108
+ }
109
+
110
+ function activationState(body: Record<string, unknown>, packageName: string): string | null {
111
+ const activations = record(body.activation)
112
+ const activation = activations === null ? null : record(activations[packageName])
113
+ return activation === null ? null : text(activation.state)
114
+ }
115
+
116
+ function rollbackIdOf(body: Record<string, unknown>): string | null {
117
+ const compatibility = record(body.compatibility)
118
+ return compatibility === null ? null : text(compatibility.rollbackId)
119
+ }
120
+
121
+ function progressView(progress?: InstallProgress): UpdateOperationV1['progress'] {
122
+ if (progress === undefined) {
123
+ return {
124
+ phase: null,
125
+ done: 0,
126
+ total: null,
127
+ percent: null,
128
+ currentPackage: null,
129
+ detail: null,
130
+ downloaded: null,
131
+ size: null,
132
+ }
133
+ }
134
+ const percent = progress.total !== null && progress.total > 0
135
+ ? Math.min(100, Math.round(progress.done / progress.total * 100))
136
+ : null
137
+ return {
138
+ phase: progress.phase,
139
+ done: progress.done,
140
+ total: progress.total,
141
+ percent,
142
+ currentPackage: progress.currentPackage,
143
+ detail: text(progress.lastLine) ?? text(progress.error),
144
+ downloaded: progress.downloaded,
145
+ size: progress.size,
146
+ }
147
+ }
148
+
149
+ /** Process-local operation registry. A boot id scopes ids across restarts. */
150
+ export class UpdateOperationStoreV1 {
151
+ private sequence = 0
152
+ private readonly operations = new Map<string, StoredOperation>()
153
+ private activeId: string | null = null
154
+
155
+ constructor(
156
+ private readonly bootId: string,
157
+ private readonly now: () => number = Date.now,
158
+ private readonly maxOperations: number = MAX_UPDATE_OPERATIONS_V1,
159
+ ) {}
160
+
161
+ hasActive(): boolean {
162
+ return this.activeId !== null
163
+ }
164
+
165
+ create(packageName: string, beforeVersion: string | null): UpdateOperationV1 {
166
+ const operationId = `${this.bootId}-update-${String(++this.sequence)}`
167
+ const createdAt = this.now()
168
+ const operation: StoredOperation = {
169
+ schema: UPDATE_API_V1_SCHEMA,
170
+ operationId,
171
+ kind: 'update',
172
+ packageName,
173
+ state: 'queued',
174
+ createdAt,
175
+ startedAt: null,
176
+ finishedAt: null,
177
+ beforeVersion,
178
+ installedVersion: beforeVersion,
179
+ progress: progressView(),
180
+ outcome: {
181
+ refreshRequired: false,
182
+ restartRequired: false,
183
+ rollback: { available: false, state: 'unavailable', detail: null },
184
+ },
185
+ failure: null,
186
+ legacyRollbackId: null,
187
+ }
188
+ this.operations.set(operationId, operation)
189
+ this.activeId = operationId
190
+ while (this.operations.size > this.maxOperations) {
191
+ const oldestId = this.operations.keys().next().value as string | undefined
192
+ if (oldestId === undefined || oldestId === this.activeId) break
193
+ this.operations.delete(oldestId)
194
+ }
195
+ return this.snapshot(operation)
196
+ }
197
+
198
+ start(operationId: string): void {
199
+ const operation = this.operations.get(operationId)
200
+ if (operation === undefined) return
201
+ operation.state = 'running'
202
+ operation.startedAt = this.now()
203
+ this.activeId = operationId
204
+ }
205
+
206
+ finish(
207
+ operationId: string,
208
+ status: number,
209
+ payload: unknown,
210
+ installedVersion: string | null,
211
+ ): UpdateOperationV1 | null {
212
+ const operation = this.operations.get(operationId)
213
+ if (operation === undefined) return null
214
+ const body = record(payload) ?? {}
215
+ const cancelled = bool(body.cancelled)
216
+ const ok = status >= 200 && status < 300 && bool(body.ok) && !cancelled
217
+ operation.state = cancelled ? 'cancelled' : ok ? 'succeeded' : 'failed'
218
+ operation.finishedAt = this.now()
219
+ operation.installedVersion = installedVersion
220
+ operation.failure = ok || cancelled ? null : failureOf(status, body)
221
+ const activation = activationState(body, operation.packageName)
222
+ operation.outcome.restartRequired = activation === 'restart'
223
+ operation.outcome.refreshRequired = bool(body.needsRefresh)
224
+ || (activation === 'live' && body.hot === true)
225
+ operation.legacyRollbackId = rollbackIdOf(body)
226
+ if (operation.legacyRollbackId !== null) {
227
+ operation.outcome.rollback.available = true
228
+ operation.outcome.rollback.state = 'available'
229
+ }
230
+ if (this.activeId === operationId) this.activeId = null
231
+ return this.snapshot(operation)
232
+ }
233
+
234
+ beginRollback(operationId: string): string | null {
235
+ const operation = this.operations.get(operationId)
236
+ if (operation?.legacyRollbackId === null || operation?.legacyRollbackId === undefined) return null
237
+ operation.outcome.rollback.state = 'running'
238
+ operation.outcome.rollback.detail = null
239
+ return operation.legacyRollbackId
240
+ }
241
+
242
+ finishRollback(
243
+ operationId: string,
244
+ status: number,
245
+ payload: unknown,
246
+ installedVersion?: string | null,
247
+ ): UpdateOperationV1 | null {
248
+ const operation = this.operations.get(operationId)
249
+ if (operation === undefined) return null
250
+ const body = record(payload) ?? {}
251
+ const ok = status >= 200 && status < 300 && body.rolledBack === true
252
+ // The rollback endpoint supplies a fresh disk read. Keep the argument
253
+ // optional so existing store consumers retain source compatibility.
254
+ if (installedVersion !== undefined) operation.installedVersion = installedVersion
255
+ operation.outcome.rollback.available = !ok && status !== 400
256
+ operation.outcome.rollback.state = ok ? 'succeeded' : 'failed'
257
+ operation.outcome.rollback.detail = ok ? null : text(body.error) ?? `rollback failed with HTTP ${String(status)}`
258
+ if (ok) {
259
+ operation.state = 'rolled-back'
260
+ operation.outcome.restartRequired = true
261
+ }
262
+ return this.snapshot(operation)
263
+ }
264
+
265
+ get(operationId: string, progress?: InstallProgress): UpdateOperationV1 | null {
266
+ const operation = this.operations.get(operationId)
267
+ if (operation === undefined) return null
268
+ const view = this.snapshot(operation)
269
+ if (this.activeId === operationId && operation.state === 'running') view.progress = progressView(progress)
270
+ return view
271
+ }
272
+
273
+ private snapshot(operation: StoredOperation): UpdateOperationV1 {
274
+ const { legacyRollbackId: _legacyRollbackId, ...view } = operation
275
+ return structuredClone(view)
276
+ }
277
+ }