@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.
- package/README.md +7 -5
- package/README.zh.md +7 -5
- package/lib/apply-08huHfLG.js +665 -0
- package/lib/apply-DmICvIGh.js +653 -0
- package/lib/apply.js +1 -1
- package/lib/bin.js +133 -37
- package/lib/types/apply.d.ts +1 -1
- package/lib/types/apply.js +56 -117
- package/lib/types/patch-set.d.ts +59 -0
- package/lib/types/patch-set.js +132 -0
- package/lib/types/standalone-cli.js +56 -2
- package/lib/types/standalone-profile.d.ts +8 -1
- package/lib/types/standalone-profile.js +84 -38
- package/package.json +625 -175
- package/patches/approval-allow-policy.patch +108 -0
- package/patches/atomic-write-zombie-lock-holder.patch +53 -0
- package/patches/bootstrap-exempt-names.patch +32 -0
- package/patches/browser-auth-mode.patch +127 -0
- package/patches/composer-popover-boundaries.patch +337 -0
- package/patches/diagram-preview.patch +562 -0
- package/patches/image-offload-hoist-replay.patch +533 -0
- package/patches/legacy-code-preset.patch +148 -0
- package/patches/llm-pi-ai-transport-truncation.patch +33 -0
- package/patches/mobile-journal-generation.patch +76 -0
- package/patches/office-preview-stable-load.patch +34 -0
- package/patches/officecli-deliverables.patch +42 -0
- package/patches/ptc-mcp-schema-types.patch +156 -0
- package/patches/responses-reasoning-status.patch +289 -0
- package/patches/session-envelope-ignorable.patch +63 -0
- package/patches/session-export-chinese.patch +85 -0
- package/patches/session-format-legacy-restart.patch +35 -0
- package/patches/session-log-trajectory-toolbar.patch +352 -0
- package/patches/state-dot-brand-blue.patch +185 -0
- package/patches/ui-settings-compact-phone.patch +76 -0
- package/patches/web-runtime-compat.patch +83 -0
- package/patches/workspace-storage-restore.patch +186 -0
- 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 {
|