@sparkelf/dsh-plus 0.2.1-rc.2.4 → 0.2.2-alpha.2.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 (37) hide show
  1. package/README.md +7 -5
  2. package/README.zh.md +7 -5
  3. package/lib/apply-08huHfLG.js +665 -0
  4. package/lib/apply-DmICvIGh.js +653 -0
  5. package/lib/apply.js +1 -1
  6. package/lib/bin.js +133 -37
  7. package/lib/types/apply.d.ts +1 -1
  8. package/lib/types/apply.js +56 -117
  9. package/lib/types/patch-set.d.ts +59 -0
  10. package/lib/types/patch-set.js +132 -0
  11. package/lib/types/standalone-cli.js +56 -2
  12. package/lib/types/standalone-profile.d.ts +8 -1
  13. package/lib/types/standalone-profile.js +84 -38
  14. package/package.json +625 -175
  15. package/patches/approval-allow-policy.patch +108 -0
  16. package/patches/atomic-write-zombie-lock-holder.patch +53 -0
  17. package/patches/bootstrap-exempt-names.patch +32 -0
  18. package/patches/browser-auth-mode.patch +127 -0
  19. package/patches/composer-popover-boundaries.patch +337 -0
  20. package/patches/diagram-preview.patch +562 -0
  21. package/patches/image-offload-hoist-replay.patch +533 -0
  22. package/patches/legacy-code-preset.patch +148 -0
  23. package/patches/llm-pi-ai-transport-truncation.patch +33 -0
  24. package/patches/mobile-journal-generation.patch +76 -0
  25. package/patches/office-preview-stable-load.patch +34 -0
  26. package/patches/officecli-deliverables.patch +42 -0
  27. package/patches/ptc-mcp-schema-types.patch +156 -0
  28. package/patches/responses-reasoning-status.patch +289 -0
  29. package/patches/session-envelope-ignorable.patch +63 -0
  30. package/patches/session-export-chinese.patch +85 -0
  31. package/patches/session-format-legacy-restart.patch +35 -0
  32. package/patches/session-log-trajectory-toolbar.patch +352 -0
  33. package/patches/state-dot-brand-blue.patch +185 -0
  34. package/patches/ui-settings-compact-phone.patch +76 -0
  35. package/patches/web-runtime-compat.patch +83 -0
  36. package/patches/workspace-storage-restore.patch +186 -0
  37. package/patches/wsl-native-open.patch +265 -0
@@ -0,0 +1,185 @@
1
+ diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css
2
+ index 1a692c6..3aa3186 100644
3
+ --- a/packages/client/ui-primitives/src/StateDot.module.css
4
+ +++ b/packages/client/ui-primitives/src/StateDot.module.css
5
+ @@ -1,4 +1,12 @@
6
+ -/* Solid states keep the 10px layout slot but paint one 6px currentColor core. */
7
+ +/* Ongoing blue has no alias token (state-business-primary is the 500 step,
8
+ + * not this 450) — component-level var pinned to the static scale instead. */
9
+ +.dot,
10
+ +.matrix {
11
+ + --dsh-state-ongoing: var(--dsw-static-deepseek-450);
12
+ +}
13
+ +
14
+ +/* Solid states: same-color halo via a 0.10-opacity outer layer (::before)
15
+ + * with a 6/10-scale solid core. Layer color rides currentColor set per state. */
16
+ .dot {
17
+ position: relative;
18
+ display: inline-block;
19
+ @@ -31,70 +39,29 @@
20
+ color: var(--dsw-alias-state-idle-primary);
21
+ }
22
+
23
+ -/* Ongoing is the sole non-dot state: a quiet full ring sits behind a centered
24
+ - * breathing arc while the complete glyph rotates continuously. Both animations
25
+ - * share one 1.5s period so pinning their start times aligns them together. */
26
+ -.spinner {
27
+ +/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe
28
+ + * holds, no tweening — the retro feel), peaking when the chase hits it and
29
+ + * decaying over the next three cells. Phase offsets come from per-rect
30
+ + * animation-delay (index * -125ms) set inline by the component. */
31
+ +.matrix {
32
+ flex: none;
33
+ - color: var(--dsw-alias-label-tertiary);
34
+ -}
35
+ -
36
+ -.spinnerMotion {
37
+ - transform-origin: center;
38
+ - animation: dsh-state-dot-spin 1.5s linear infinite;
39
+ -}
40
+ -
41
+ -.spinnerTrack,
42
+ -.spinnerArc {
43
+ - fill: none;
44
+ - stroke: currentColor;
45
+ - stroke-width: 2;
46
+ - stroke-linecap: round;
47
+ -}
48
+ -
49
+ -.spinnerTrack {
50
+ - opacity: 0.25;
51
+ + color: var(--dsh-state-ongoing);
52
+ }
53
+
54
+ -.spinnerArc {
55
+ - stroke-dasharray: 12 150;
56
+ - animation: dsh-state-dot-dash 1.5s ease-in-out infinite;
57
+ +.cell {
58
+ + fill: currentColor;
59
+ + opacity: 0.15;
60
+ + animation: dsh-state-dot-chase 1s infinite;
61
+ }
62
+
63
+ -@keyframes dsh-state-dot-spin {
64
+ - to { transform: rotate(360deg); }
65
+ -}
66
+ -
67
+ -@keyframes dsh-state-dot-dash {
68
+ - 0% {
69
+ - stroke-dasharray: 12 150;
70
+ - stroke-dashoffset: 0;
71
+ - }
72
+ -
73
+ - 50% {
74
+ - stroke-dasharray: 24 150;
75
+ - stroke-dashoffset: -6;
76
+ - }
77
+ -
78
+ - 100% {
79
+ - stroke-dasharray: 12 150;
80
+ - stroke-dashoffset: 0;
81
+ - }
82
+ -}
83
+ -
84
+ -@media (prefers-reduced-motion: reduce) {
85
+ - .spinnerMotion,
86
+ - .spinnerArc {
87
+ - animation: none;
88
+ - }
89
+ -
90
+ - .spinnerArc {
91
+ - stroke-dasharray: 18 150;
92
+ - stroke-dashoffset: -3;
93
+ - }
94
+ +@keyframes dsh-state-dot-chase {
95
+ + 0%, 12.4% { opacity: 1; }
96
+ + 12.5%, 24.9% { opacity: 0.6; }
97
+ + 25%, 37.4% { opacity: 0.35; }
98
+ + 37.5%, 100% { opacity: 0.15; }
99
+ }
100
+
101
+ .step { display: inline-flex; flex: none; align-items: center; justify-content: center; box-sizing: border-box; border-radius: 50%; corner-shape: round; border: 1.5px solid var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-primary-foreground); }
102
+ .step[data-state='done'] { border-color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-primary); }
103
+ .step[data-state='error'] { border-color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-primary); }
104
+ -.step[data-state='warning'] { border-color: var(--dsw-alias-state-warn-primary); }
105
+ +.step[data-state='warning'] { border-color: var(--dsw-alias-state-warn-primary); }
106
+
107
+ diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx
108
+ index ec480c6..f54ebdd 100644
109
+ --- a/packages/client/ui-primitives/src/StateDot.tsx
110
+ +++ b/packages/client/ui-primitives/src/StateDot.tsx
111
+ @@ -3,35 +3,15 @@ import css from './StateDot.module.css'
112
+ import { IconCheckOutlineRegular } from './icons/index.tsx'
113
+
114
+ /**
115
+ - * State semantic: green done / amber user-attention / tertiary-grey loading /
116
+ + * State semantic: green done / amber user-attention / blue running ring /
117
+ * red error / neutral-grey idle for a tracked subject with nothing in progress.
118
+ */
119
+ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' | 'idle'
120
+
121
+ -/**
122
+ - * Pin the loader's CSS animations to document time zero. A CSS animation starts
123
+ - * when its element is inserted and restarts when the element is moved or shown
124
+ - * again, so loaders started at different moments rotate out of phase; one shared
125
+ - * start time keeps every visible loader in step. Mount pins the first start; the
126
+ - * element's own `animationstart` listener pins each restart.
127
+ - * @param element - the mounted loader, or null on unmount.
128
+ - */
129
+ -function syncSpinner(element: SVGSVGElement | null): void {
130
+ - if (element === null) return
131
+ - pinSpinner(element)
132
+ - element.addEventListener('animationstart', () => { pinSpinner(element) })
133
+ -}
134
+ -
135
+ -/**
136
+ - * Set every CSS animation in the loader's subtree to start at document time zero.
137
+ - * @param element - the mounted loader.
138
+ - */
139
+ -function pinSpinner(element: SVGSVGElement): void {
140
+ - // jsdom (the unit lane) implements no Web Animations despite lib.dom's
141
+ - // non-optional typing; the optional call leaves that lane unsynced.
142
+ - const spinner = element as { getAnimations?: SVGSVGElement['getAnimations'] }
143
+ - for (const animation of spinner.getAnimations?.({ subtree: true }) ?? []) animation.startTime = 0
144
+ -}
145
+ +/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */
146
+ +const MATRIX_CELLS: readonly (readonly [number, number])[] = [
147
+ + [0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4],
148
+ +]
149
+
150
+ /**
151
+ * Render a state dot.
152
+ @@ -51,18 +31,26 @@ export function StateDot({ state, size, className, appearance = 'dot' }: {
153
+ if (state === 'ongoing') {
154
+ return (
155
+ <svg
156
+ - ref={syncSpinner}
157
+ - className={clsx(css.spinner, className)}
158
+ + className={clsx(css.matrix, className)}
159
+ data-state="ongoing"
160
+ width={edge}
161
+ height={edge}
162
+ - viewBox="0 0 24 24"
163
+ + viewBox="0 0 10 10"
164
+ + shapeRendering="crispEdges"
165
+ aria-hidden="true"
166
+ >
167
+ - <g className={css.spinnerMotion}>
168
+ - <circle className={css.spinnerTrack} cx="12" cy="12" r="9.5" />
169
+ - <circle className={css.spinnerArc} cx="12" cy="12" r="9.5" />
170
+ - </g>
171
+ + {MATRIX_CELLS.map(([x, y], index) => (
172
+ + <rect
173
+ + key={`${x}-${y}`}
174
+ + className={css.cell}
175
+ + x={x}
176
+ + y={y}
177
+ + width="2"
178
+ + height="2"
179
+ + /* Negative delay phases the chase so every cell animates from mount. */
180
+ + style={{ animationDelay: `${(index - MATRIX_CELLS.length) * 125}ms` }}
181
+ + />
182
+ + ))}
183
+ </svg>
184
+ )
185
+ }
@@ -0,0 +1,76 @@
1
+ diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css
2
+ --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css
3
+ +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css
4
+ @@ -246,3 +246,64 @@
5
+ ui-web base.css's `body > :not(#root)` subtraction covers it wherever it
6
+ overlaps a chrome row's drag — including above every column, which an in-root
7
+ overlay could not promise once a chrome row declares drag itself. */
8
+ +
9
+ +/*
10
+ + * A phone has no room for the desktop two-column shell. The rail keeps its 188px and the panel is
11
+ + * capped at 100vw - 48px, which leaves the content about a hundred pixels: labels wrap to one
12
+ + * character per line and a pairing QR computes to zero. Below this width the panel fills the
13
+ + * viewport and the rail becomes a horizontal strip above the content, so the content keeps the
14
+ + * panel's own width and the phone's safe areas are respected.
15
+ + */
16
+ +@media (max-width: 640px) {
17
+ + .overlay {
18
+ + align-items: stretch;
19
+ + justify-content: stretch;
20
+ + }
21
+ +
22
+ + .panel {
23
+ + width: 100vw;
24
+ + max-width: 100vw;
25
+ + height: 100vh;
26
+ + height: 100dvh;
27
+ + border-radius: 0;
28
+ + }
29
+ +
30
+ + /* The panel stacks vertically so the rail sits above the content instead of beside it. */
31
+ + .panel {
32
+ + flex-direction: column;
33
+ + }
34
+ +
35
+ + .nav {
36
+ + flex: none;
37
+ + width: 100%;
38
+ + gap: 10px;
39
+ + padding: max(12px, env(safe-area-inset-top)) 14px 0;
40
+ + }
41
+ +
42
+ + .navTitle {
43
+ + padding: 0 2px;
44
+ + }
45
+ +
46
+ + /* One scrollable row of destinations instead of a fixed 188px column. */
47
+ + .navList {
48
+ + flex-direction: row;
49
+ + gap: 8px;
50
+ + overflow-x: auto;
51
+ + overflow-y: hidden;
52
+ + scrollbar-width: none;
53
+ + padding-bottom: 4px;
54
+ + }
55
+ +
56
+ + .navList::-webkit-scrollbar {
57
+ + display: none;
58
+ + }
59
+ +
60
+ + .navCell {
61
+ + flex: 1 0 auto;
62
+ + width: auto;
63
+ + min-height: 44px;
64
+ + white-space: nowrap;
65
+ + }
66
+ +
67
+ + /* Takes the space the rail leaves and scrolls its own options. */
68
+ + .content {
69
+ + flex: 1;
70
+ + min-height: 0;
71
+ + }
72
+ +
73
+ + .options {
74
+ + padding-bottom: env(safe-area-inset-bottom);
75
+ + }
76
+ +}
@@ -0,0 +1,83 @@
1
+ diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts
2
+ index b93b559620..8b40aae24d 100644
3
+ --- a/packages/bundle/web-app/src/index.ts
4
+ +++ b/packages/bundle/web-app/src/index.ts
5
+ @@ -14,13 +14,14 @@
6
+
7
+ import { spawn, type ChildProcess } from 'node:child_process'
8
+ import { createRequire } from 'node:module'
9
+ +import { networkInterfaces } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import z from '@deepseek-ai/schemastery'
13
+ import { auditStartupEntries } from '@deepseek-ai/dsh-app-boot'
14
+ import type {} from '@deepseek-ai/dsh-client-connection'
15
+ import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static'
16
+ -import { bindUrlHost, isLoopbackHost } from '@deepseek-ai/dsh-host-webserver'
17
+ +import { bindUrlHost, isLoopbackHost, isWildcardHost } from '@deepseek-ai/dsh-host-webserver'
18
+ import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
19
+ import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
20
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
21
+ @@ -70,6 +71,17 @@ export interface Config {
22
+ * Absent or YAML `null` advertises the bind-address URL.
23
+ */
24
+ publicUrl?: string
25
+ + /**
26
+ + * Non-loopback authorities a deployment serves this GUI on, appended to the
27
+ + * sampled LAN literals. The listener's own bind address is accepted
28
+ + * separately, so this list carries only what the bind cannot name: a reverse
29
+ + * proxy's host, or a public origin a browser actually uses.
30
+ + *
31
+ + * Superseded by the `connection` row's own `trustedHosts`, which is where a
32
+ + * deployment adds authorities today. Kept so a profile layer written against
33
+ + * the pre-rename row keeps reaching the host fence.
34
+ + */
35
+ + trustedHosts?: string[]
36
+ }
37
+
38
+ export const Config: z<Config> = z.object({
39
+ @@ -77,6 +89,7 @@ export const Config: z<Config> = z.object({
40
+ printUrl: z.boolean().default(true),
41
+ surfaceContext: z.boolean().default(true),
42
+ publicUrl: z.transform(z.string(), value => parsePublicUrl(value).href),
43
+ + trustedHosts: z.array(String).default([]),
44
+ })
45
+
46
+ /** Environment variable naming the advertised URL of this Web GUI. */
47
+ @@ -204,6 +217,36 @@ export function apply(ctx: Context, config: Config): void {
48
+ const appRootUrl = (): string => publicUrl ?? ctx.webServer.getOrigin()
49
+ ctx.provide('webApp', { getAdvertisedUrl: appRootUrl } satisfies WebApp)
50
+ ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
51
+ +
52
+ + // WEB_RUNTIME_COMPAT: alpha.2 renamed the bind-dependent service from
53
+ + // `webRuntime` to `webStartup` and taught its own rows the new name. A
54
+ + // third-party bundle cannot follow the rename: dsh-better-sidebar and
55
+ + // dsh-video-preview declare `inject: [..., 'webRuntime', ...]`, and Cordis
56
+ + // holds an injected row pending until the named service appears. A deployment
57
+ + // that mounts them loses the sidebar routes and the video preview that are the
58
+ + // whole reason they are mounted, so the old shape is published beside the new
59
+ + // one for as long as those bundles ask for it.
60
+ + //
61
+ + // The shape is the pre-rename service: LAN literals sampled once from a
62
+ + // wildcard bind, then this invocation's and the deployment's own authorities.
63
+ + // `webStartup` already carries the flag-supplied half, so it is read rather
64
+ + // than re-parsed, and `isWildcardHost` is the webserver's own predicate
65
+ + // rather than a string comparison against the IPv4 any-address. This row's
66
+ + // `trustedHosts` reaches the fence again here: alpha.2 moved the deployment
67
+ + // list to the `connection` row, which leaves a profile layer written against
68
+ + // the old row — one that serves a reverse-proxied origin — configuring a key
69
+ + // nothing reads.
70
+ + const compatLanAddresses = isWildcardHost(boundHost)
71
+ + ? Object.values(networkInterfaces()).flat()
72
+ + .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
73
+ + .map(iface => iface.address)
74
+ + : []
75
+ + const compatStartup = ctx.get('webStartup') as { trustedHosts?: readonly string[] } | undefined
76
+ + ctx.provide('webRuntime', {
77
+ + lanAddresses: compatLanAddresses,
78
+ + trustedHosts: [...compatLanAddresses, ...config.trustedHosts ?? [], ...compatStartup?.trustedHosts ?? []],
79
+ + })
80
+ +
81
+ if (config.surfaceContext) {
82
+ ctx.inject(['shellEnv'], (runtimeCtx) => {
83
+ runtimeCtx.shellEnv.register({
@@ -0,0 +1,186 @@
1
+ diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts
2
+ index 00eaf0030c..b6e761e1cb 100644
3
+ --- a/packages/workspace/workspace/src/index.ts
4
+ +++ b/packages/workspace/workspace/src/index.ts
5
+ @@ -10,7 +10,7 @@ import { mkdir, stat } from 'node:fs/promises'
6
+ import { Context, Service } from '@deepseek-ai/cordis'
7
+ import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
8
+ import type {} from '@deepseek-ai/dsh-session-persistence'
9
+ -import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain'
10
+ +import type { Domain, DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain'
11
+ import { WorkspaceEntity } from './entity.ts'
12
+ import type { WorkspaceEntityHost } from './entity.ts'
13
+
14
+ @@ -170,6 +170,7 @@ const compareHeaders = (left: SessionHeader, right: SessionHeader): number =>
15
+ export class WorkspaceRegistry extends Service {
16
+ static inject = ['storageDomain', 'sessionPersistence']
17
+
18
+ + private domain?: Domain<typeof workspaceDomainSpec>
19
+ private table?: KvTable<WorkspaceId, WorkspaceRecord>
20
+ private global?: DomainGlobal<WorkspaceDomainState>
21
+ private state?: WorkspaceDomainState
22
+ @@ -195,8 +196,53 @@ export class WorkspaceRegistry extends Service {
23
+
24
+ /** Open the domain, finish bootstrap when required, and rebuild the ordered cache. */
25
+ protected async [Service.init](): Promise<void> {
26
+ + this.ctx.effect(() => () => this.domain?.close(), 'workspace.domainClose')
27
+ + await this.openDomain()
28
+ + }
29
+ +
30
+ + /**
31
+ + * Replace external durable files while no Workspace storage handle is open.
32
+ + * Existing mutations settle first; the domain always reopens through startup
33
+ + * initialization so records, archive state, and Session indexes share one new
34
+ + * baseline.
35
+ + * @param restore - File replacement operation run while Workspace storage is closed.
36
+ + * @returns the operation result after the new durable baseline is available.
37
+ + */
38
+ + async withStorageRestore<T>(restore: () => Promise<T>): Promise<T> {
39
+ + // The restore replaces durable files, so the domain must be closed first;
40
+ + // the finally clause reuses the startup path to rebuild every cache.
41
+ + return await this.enqueueOperation(async () => {
42
+ + // The queue slot runs recoverPendingMutation first, which requires the
43
+ + // registry state, so the open domain is present by this point.
44
+ + const domain = this.domain as Domain<typeof workspaceDomainSpec>
45
+ + const previousArchivedSessions = this.requireState().archivedSessionIds.length
46
+ + await domain.close()
47
+ + delete this.domain
48
+ + delete this.table
49
+ + delete this.global
50
+ + delete this.state
51
+ + this.entities.clear()
52
+ + this.headers.clear()
53
+ + this.sessionPaths.clear()
54
+ + this.invalidSessionPaths.clear()
55
+ + try {
56
+ + return await restore()
57
+ + } finally {
58
+ + await this.openDomain()
59
+ + console.info('[workspace] storage restore baseline loaded', {
60
+ + previousArchivedSessions,
61
+ + restoredArchivedSessions: this.requireState().archivedSessionIds.length,
62
+ + workspaces: this.entities.size,
63
+ + indexedSessionHeaders: this.headers.size,
64
+ + })
65
+ + }
66
+ + })
67
+ + }
68
+ +
69
+ + /** Open the durable domain and rebuild every registry cache from its contents. */
70
+ + private async openDomain(): Promise<void> {
71
+ const domain = await this.ctx.storageDomain.open(workspaceDomainSpec)
72
+ - this.ctx.effect(() => () => domain.close(), 'workspace.domainClose')
73
+ + this.domain = domain
74
+ this.table = domain.table('workspaces')
75
+ this.global = domain.global
76
+ this.state = domain.global.get()
77
+ diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts
78
+ index ef3116b3d7..67721e6474 100644
79
+ --- a/packages/workspace/workspace/tests/workspace.spec.ts
80
+ +++ b/packages/workspace/workspace/tests/workspace.spec.ts
81
+ @@ -1378,6 +1378,105 @@ describe('first-use Workspace preparation', () => {
82
+ })
83
+ })
84
+
85
+ +describe('WorkspaceRegistry storage restore', () => {
86
+ + const contexts: Context[] = []
87
+ +
88
+ + afterEach(async () => {
89
+ + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
90
+ + })
91
+ +
92
+ + it('serves the replaced baseline, not the pre-restore window, once the restore returns', async () => {
93
+ + const kept = await makeDir('restore-kept')
94
+ + const restored = await makeDir('restore-kept-2')
95
+ + const h = await harness({ sessions: [header('kept', kept, 100)] })
96
+ + contexts.push(h.ctx)
97
+ + const created = await h.registry.create(kept)
98
+ + expect(h.registry.list().map(workspace => workspace.id)).toEqual([created.id])
99
+ +
100
+ + // A restore replaces the durable medium wholesale: the registry must
101
+ + // observe the replacement only after reopening, never the old window.
102
+ + const id = WorkspaceId('restored-workspace')
103
+ + const replaced = await h.registry.withStorageRestore(async () => {
104
+ + const medium = h.pool.media.get('workspace')!
105
+ + medium.tables.set('workspaces', new Map([[id, {
106
+ + path: restored,
107
+ + title: 'restored',
108
+ + sessionIds: [],
109
+ + createdAt: '1970-01-01T00:00:01.000Z',
110
+ + updatedAt: '1970-01-01T00:00:01.000Z',
111
+ + }]]))
112
+ + medium.global = {
113
+ + initialized: true,
114
+ + workspaceIds: [id],
115
+ + archivedSessionIds: [SessionId('archived-before-restore')],
116
+ + pinnedSessionIds: [],
117
+ + }
118
+ + return 'replaced'
119
+ + })
120
+ +
121
+ + expect(replaced).toBe('replaced')
122
+ + // Both the record set and the registry-global archive set come from the
123
+ + // reopened domain; the pre-restore workspace and archive set are gone.
124
+ + expect(h.registry.list().map(workspace => workspace.path)).toEqual([restored])
125
+ + expect(h.registry.archivedSessionIds).toEqual([SessionId('archived-before-restore')])
126
+ + expect(h.registry.list().map(workspace => workspace.id)).not.toContain(created.id)
127
+ + })
128
+ +
129
+ + it('reopens through startup bootstrap so a restored empty baseline is rebuilt', async () => {
130
+ + const dir = await makeDir('restore-rebuild')
131
+ + const h = await harness({ sessions: [header('rebuild', dir, 100)] })
132
+ + contexts.push(h.ctx)
133
+ + await h.registry.create(dir)
134
+ + expect(h.registry.list()).toHaveLength(1)
135
+ +
136
+ + await h.registry.withStorageRestore(async () => {
137
+ + const medium = h.pool.media.get('workspace')!
138
+ + medium.tables.set('workspaces', new Map())
139
+ + medium.global = null
140
+ + })
141
+ +
142
+ + // The restored medium carries no initialized marker, so the reopened
143
+ + // registry bootstraps the header history again instead of serving a stale cache.
144
+ + expect(storedState(h.pool).initialized).toBe(true)
145
+ + expect(h.registry.list()).toHaveLength(1)
146
+ + })
147
+ +
148
+ + it('rebuilds every cache on the restored baseline after the replacement throws', async () => {
149
+ + const dir = await makeDir('restore-throws')
150
+ + const h = await harness({ sessions: [header('throws', dir, 100)] })
151
+ + contexts.push(h.ctx)
152
+ + await h.registry.create(dir)
153
+ +
154
+ + await expect(h.registry.withStorageRestore(async () => {
155
+ + const medium = h.pool.media.get('workspace')!
156
+ + medium.tables.set('workspaces', new Map())
157
+ + medium.global = null
158
+ + throw new Error('replacement failed')
159
+ + })).rejects.toThrow('replacement failed')
160
+ +
161
+ + // The reopen runs in the finally arm, so the registry is still usable:
162
+ + // the restored empty medium is bootstrapped from the Session headers again.
163
+ + expect(h.registry.list().map(workspace => workspace.path)).toEqual([dir])
164
+ + await expect(h.registry.create(dir)).resolves.toBeDefined()
165
+ + })
166
+ +
167
+ + it('serializes a queued mutation behind the restore it was issued during', async () => {
168
+ + const dir = await makeDir('restore-queued')
169
+ + const h = await harness({ sessions: [header('queued', dir, 100)] })
170
+ + contexts.push(h.ctx)
171
+ + const created = await h.registry.create(dir)
172
+ + const order: string[] = []
173
+ + // The restore takes the registry queue slot synchronously, so the delete
174
+ + // issued next runs only after the domain was closed and reopened.
175
+ + const restoring = h.registry.withStorageRestore(async () => { order.push('restore') })
176
+ + const deleting = h.registry.delete(created.id).then((ok) => { order.push('delete'); return ok })
177
+ + await Promise.all([restoring, deleting])
178
+ + expect(order).toEqual(['restore', 'delete'])
179
+ + expect(await deleting).toBe(true)
180
+ + expect(h.registry.list()).toEqual([])
181
+ + })
182
+ +})
183
+ +
184
+ // The registry knows no family: the providers merge theirs, and this suite merges its own.
185
+ declare module '@deepseek-ai/dsh-workspace/types' {
186
+ interface SessionActivityKindMap {