dsh-home-hosted 0.9.0 → 0.9.2

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/docs/DESIGN.md CHANGED
@@ -38,8 +38,8 @@ its page.
38
38
  `onPortConflict: kill` (0.6.0), `persistent` (0.6.3), `restart <id>` (0.7.13) and the nullable-`label`
39
39
  patch (0.7.21) all predate the release this plugin pins. The plugin used to consult the answering
40
40
  panel's version and refuse or drop those keys (`KILL_UNSUPPORTED`), and later to retry a `restart` as a
41
- stop-then-start pair. Both are gone: it pins `home-hosted@^0.9.0`, which refuses an older panel
42
- outright, so neither check could ever fire again.
41
+ stop-then-start pair. Both are gone: it pins `home-hosted@^0.9.x` — 0.9.0 at the time of writing —
42
+ which refuses an older panel outright, so neither check could ever fire again.
43
43
 
44
44
  An old state root is upgraded by running the latest 0.7.x **once, before installing 0.8** — its first
45
45
  run moves the state into `.hh/`. A root a 0.8-era panel left on config schema 1 needs
package/docs/PANEL.md CHANGED
@@ -69,11 +69,17 @@ a config it created before any panel had run was unstartable on 0.9. It now stam
69
69
  every file it **creates**, reading the number from the pinned release rather than hard-coding it — a
70
70
  fixed `2` would break again at the next bump, from the other side.
71
71
 
72
- A file that already exists **without** a stamp is refused instead of stamped: its content may still
73
- need lifting, and stamping it would claim a migration that never ran and silently strand what the step
74
- rewrites. Measured: a schema-2 stamp over an old `notifications.telegram` block makes 0.9 report the
75
- key as unrecognized and ignore it, so the person's notifications end up silently off. Lifting a config
76
- is `home-hosted migrate`'s job, and the refusal names it.
72
+ An existing file that does not already declare **the** schema the pinned panel reads is refused, in
73
+ both directions: below it needs `home-hosted migrate`, above it was written by a newer home-hosted
74
+ than this plugin pins. Stamping over either would claim a migration that never ran — measured, a
75
+ schema-2 stamp over an old `notifications.telegram` block makes 0.9 report the key as unrecognized and
76
+ ignore it, so the person's notifications end up silently off — and writing on regardless would leave
77
+ this plugin editing a panel that cannot boot while every call reports success. Lifting or downgrading
78
+ a config is `home-hosted migrate`'s job, and the refusal names the remedy for the direction it found.
79
+
80
+ The page says so **before** a write is attempted: a config the panel cannot boot is reported as the
81
+ panel's error, so the root is not shown as healthy until the first call fails. The entries are still
82
+ listed — the file is readable, it is only un-bootable — so nothing looks lost while you migrate it.
77
83
 
78
84
  The floor is `0.9.0` for the same reason the pin is: a 0.8.x panel reads a schema-2 stamp as a *newer*
79
85
  release's file and refuses that in turn, so no stamp satisfies both and there is no version-conditional
@@ -102,6 +108,22 @@ recreating its own log, an unflushed log batch, a Windows test skip — touch no
102
108
  no `.proxy/proxy.json`, `ProxyService`, `terminatePid`, or `DELETE /api/logs`, and `serverSchema`,
103
109
  `CONFIG_SCHEMA`, `/_hh` and the CLI are unchanged.
104
110
 
111
+ **0.9.4 also needs no adaptation, and that was checked by use.** It adds no command and no required
112
+ field: `PUT /api/servers/:id`, `POST /api/servers/orphans/:id/stop`, presets and restore reporting are
113
+ new routes and UI, none of which this plugin calls; `stoppedByPanel`, `orphans`, `healthRestarts` and
114
+ the action-result `code` are new *optional* fields. `serverSchema`, `editableFields`, the merge keys,
115
+ `CONFIG_SCHEMA` (still 2), the four `/_hh` routes and every CLI subcommand and flag are unchanged, so
116
+ the pin moved and the floor did not. Verified by diffing the published sourcemaps of 0.9.2 and 0.9.4
117
+ (34 changed files) and by driving a real 0.9.4 panel: this plugin's client lists, creates, patches,
118
+ starts, stops and deletes an entry, and reads workspaces and settings, exactly as on 0.9.2.
119
+
120
+ Two 0.9.4 behaviour changes reach the page without breaking it. `/healthz` now answers 503 for **any**
121
+ crashed server rather than only an `autostart` one — measured, and harmless here because `probePanel`
122
+ asks whether the panel answered at all, not whether it said 200. And the supervisor now bounds health
123
+ force-restarts and then **stops** the entry, reporting it as `stopped` with `stoppedByPanel: true`; the
124
+ page renders that as a plain `stopped`, so it reads like a deliberate stop. That is a fidelity gap in
125
+ the status chip, not a break: the reason is still on the panel's own card and in `lastError`.
126
+
105
127
  That copy resolves to a pnpm path carrying a version and a peer hash, which moves on the next install
106
128
  and disappears when the profile is rebuilt. A boot entry that baked it in would fail exactly when it
107
129
  matters. So the entry runs a generated launcher in the plugin state dir (`bin/home-hosted.mjs`,
package/lib/index.js CHANGED
@@ -109,6 +109,63 @@ var DEFAULT_SETTINGS = {
109
109
  cli: { prefer: "pinned" }
110
110
  };
111
111
 
112
+ // src/rpc.ts
113
+ function answer(endpoint, result) {
114
+ return Response.json({ v: RPC_VERSION, endpoint, result });
115
+ }
116
+ function registerRpc(ctx, service) {
117
+ ctx.inject(["connection"], (scoped) => {
118
+ const connection = scoped.connection;
119
+ if (connection?.fetch?.register === void 0)
120
+ return;
121
+ scoped.effect(() => {
122
+ let dispose;
123
+ try {
124
+ dispose = connection.fetch.register({
125
+ // The exact-route registry is keyed by the full request path, so the
126
+ // `/api` prefix belongs here; the page composes the same URL from
127
+ // API_BASE + RPC_PATH.
128
+ path: `/api${RPC_PATH}`,
129
+ methods: ["POST"],
130
+ requestBody: "buffered",
131
+ fetch: async (request) => {
132
+ let body;
133
+ try {
134
+ body = await request.json();
135
+ } catch {
136
+ return Response.json({ error: "the request body is not JSON" }, { status: 400 });
137
+ }
138
+ if (body === null || typeof body !== "object" || body.v !== RPC_VERSION) {
139
+ return Response.json(
140
+ { error: `this page and the host disagree on the protocol version (expected ${String(RPC_VERSION)})` },
141
+ { status: 409 }
142
+ );
143
+ }
144
+ try {
145
+ const value = await service.call(body.endpoint, body.payload);
146
+ return answer(body.endpoint, { ok: true, value });
147
+ } catch (error) {
148
+ const code = error?.code;
149
+ const message2 = error instanceof Error ? error.message : String(error);
150
+ const detail = error?.detail;
151
+ return answer(body.endpoint, { ok: false, error: { code: typeof code === "string" ? code : "INTERNAL", message: message2, ...detail === void 0 ? {} : { detail } } });
152
+ }
153
+ }
154
+ });
155
+ } catch (error) {
156
+ const logger = scoped.logger;
157
+ logger?.warn?.(`[dsh-home-hosted] could not register ${RPC_PATH}: ${error instanceof Error ? error.message : String(error)}`);
158
+ return () => {
159
+ };
160
+ }
161
+ return () => {
162
+ void Promise.resolve(dispose()).catch(() => {
163
+ });
164
+ };
165
+ }, "dsh-home-hosted: rpc route");
166
+ });
167
+ }
168
+
112
169
  // src/service.ts
113
170
  import { Service } from "@deepseek-ai/cordis";
114
171
  import fs18 from "node:fs";
@@ -2960,7 +3017,7 @@ function buildHomeHostedBootSpec(facts, launch, options) {
2960
3017
  }
2961
3018
 
2962
3019
  // src/home-hosted/resolve.ts
2963
- var EXPECTED_RANGE = "^0.9.0";
3020
+ var EXPECTED_RANGE = "^0.9.4";
2964
3021
  var MIN_SUPPORTED_VERSION = "0.9.0";
2965
3022
  function pinnedManifestPath() {
2966
3023
  try {
@@ -3176,9 +3233,12 @@ function readConfigSchemaFrom(manifestPath) {
3176
3233
  // src/home-hosted/config-file.ts
3177
3234
  var ConfigLayoutError = class extends Error {
3178
3235
  name = "ConfigLayoutError";
3236
+ /** The wire code a caller reports; the root's layout is a readable config problem, not a crash. */
3237
+ code = "CONFIG_UNREADABLE";
3179
3238
  };
3180
3239
  var ConfigPatchError = class extends Error {
3181
3240
  name = "ConfigPatchError";
3241
+ code = "INVALID_PATCH";
3182
3242
  };
3183
3243
  function readConfig(home, workspace = DEFAULT_WORKSPACE) {
3184
3244
  const refusal = migrationRefusal(home);
@@ -3208,17 +3268,30 @@ function writeConfig(home, raw, writtenBy = "dsh-home-hosted", workspace = DEFAU
3208
3268
  writeFileAtomic(file, `${JSON.stringify(next, null, 2)}
3209
3269
  `);
3210
3270
  }
3271
+ function schemaRefusal(meta, where, creating) {
3272
+ const pinned = panelConfigSchema();
3273
+ if (pinned === null)
3274
+ return null;
3275
+ const declared = isRecord(meta) && typeof meta.schema === "number" ? meta.schema : null;
3276
+ if (declared === null) {
3277
+ return creating ? null : `${where} carries no meta.schema, so its config shape is unknown; run \`home-hosted migrate\` before this plugin writes it`;
3278
+ }
3279
+ if (declared === pinned)
3280
+ return null;
3281
+ return declared < pinned ? `${where} is on config schema ${declared}, which home-hosted ${pinned} refuses to boot; run \`home-hosted migrate\` before this plugin writes it` : `${where} is on config schema ${declared}, written by a newer home-hosted than this plugin's ${pinned}; update home-hosted, or pin this plugin to the release that wrote it`;
3282
+ }
3211
3283
  function stampedMeta(existing, writtenBy, where, file) {
3212
- const meta = isRecord(existing) ? { ...existing } : {};
3213
- if (typeof meta.schema !== "number") {
3214
- if (fs12.existsSync(file)) {
3215
- throw new ConfigLayoutError(
3216
- `${where} carries no meta.schema, so its config shape is unknown; run \`home-hosted migrate\` before this plugin writes it`
3217
- );
3218
- }
3219
- const schema = panelConfigSchema();
3220
- if (schema !== null)
3221
- meta.schema = schema;
3284
+ const onDisk = fs12.existsSync(file) ? readJson(file) : null;
3285
+ const diskMeta = isRecord(onDisk) && isRecord(onDisk.meta) ? onDisk.meta : {};
3286
+ const meta = { ...diskMeta, ...isRecord(existing) ? existing : {} };
3287
+ if (onDisk === null) {
3288
+ const pinned = panelConfigSchema();
3289
+ if (pinned !== null)
3290
+ meta.schema = pinned;
3291
+ } else {
3292
+ const refusal = schemaRefusal(meta, where, false);
3293
+ if (refusal !== null)
3294
+ throw new ConfigLayoutError(refusal);
3222
3295
  }
3223
3296
  meta.writtenBy = writtenBy;
3224
3297
  return meta;
@@ -6461,6 +6534,8 @@ var HomeHostedService = class extends Service {
6461
6534
  servers = read.servers;
6462
6535
  if (read.error !== null)
6463
6536
  lastError ??= read.error;
6537
+ const file = readConfig(this.options.home, this.managedWorkspace());
6538
+ lastError ??= schemaRefusal(file.raw?.meta, `servers.config.json in ${this.managedWorkspace()}`, !file.exists);
6464
6539
  }
6465
6540
  return {
6466
6541
  defaultEntryId: this.options.defaultEntryId,
@@ -6694,63 +6769,6 @@ var HomeHostedService = class extends Service {
6694
6769
  }
6695
6770
  };
6696
6771
 
6697
- // src/rpc.ts
6698
- function answer(endpoint, result) {
6699
- return Response.json({ v: RPC_VERSION, endpoint, result });
6700
- }
6701
- function registerRpc(ctx, service) {
6702
- ctx.inject(["connection"], (scoped) => {
6703
- const connection = scoped.connection;
6704
- if (connection?.fetch?.register === void 0)
6705
- return;
6706
- scoped.effect(() => {
6707
- let dispose;
6708
- try {
6709
- dispose = connection.fetch.register({
6710
- // The exact-route registry is keyed by the full request path, so the
6711
- // `/api` prefix belongs here; the page composes the same URL from
6712
- // API_BASE + RPC_PATH.
6713
- path: `/api${RPC_PATH}`,
6714
- methods: ["POST"],
6715
- requestBody: "buffered",
6716
- fetch: async (request) => {
6717
- let body;
6718
- try {
6719
- body = await request.json();
6720
- } catch {
6721
- return Response.json({ error: "the request body is not JSON" }, { status: 400 });
6722
- }
6723
- if (body === null || typeof body !== "object" || body.v !== RPC_VERSION) {
6724
- return Response.json(
6725
- { error: `this page and the host disagree on the protocol version (expected ${String(RPC_VERSION)})` },
6726
- { status: 409 }
6727
- );
6728
- }
6729
- try {
6730
- const value = await service.call(body.endpoint, body.payload);
6731
- return answer(body.endpoint, { ok: true, value });
6732
- } catch (error) {
6733
- const code = error instanceof HomeHostedError ? error.code : "INTERNAL";
6734
- const message2 = error instanceof Error ? error.message : String(error);
6735
- const detail = error?.detail;
6736
- return answer(body.endpoint, { ok: false, error: { code, message: message2, ...detail === void 0 ? {} : { detail } } });
6737
- }
6738
- }
6739
- });
6740
- } catch (error) {
6741
- const logger = scoped.logger;
6742
- logger?.warn?.(`[dsh-home-hosted] could not register ${RPC_PATH}: ${error instanceof Error ? error.message : String(error)}`);
6743
- return () => {
6744
- };
6745
- }
6746
- return () => {
6747
- void Promise.resolve(dispose()).catch(() => {
6748
- });
6749
- };
6750
- }, "dsh-home-hosted: rpc route");
6751
- });
6752
- }
6753
-
6754
6772
  // src/home-hosted/instances-notice.ts
6755
6773
  var INSTANCES_CONTEXT_ORDER = 130;
6756
6774
  function instancesContextText(instances, enabled) {