@melite/harness-provisioner 0.1.4-alpha → 0.2.0-alpha

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 CHANGED
@@ -1,3 +1,8 @@
1
+ ---
2
+ description: The vanilla-DSH provisioner — mounts the Melite estate bundle and renders ready-to-boot profiles into any DSH_HOME.
3
+ kind: package-bundle
4
+ ---
5
+
1
6
  # @melite/harness-provisioner
2
7
 
3
8
  The provisioner mounts the Melite estate into a vanilla DSH installation and
@@ -19,12 +24,13 @@ The workspace-linked bundle mounts the estate host row and its DSH surfaces:
19
24
 
20
25
  The package's own `cordis.patch.yml` and the packages' self-registration rows
21
26
  own the Cordis composition. This README does not duplicate those rows.
27
+ Declared exports: `.` (the workspace-linked bundle row) and `./package.json`.
22
28
 
23
29
  ## Render profiles
24
30
 
25
31
  ```sh
26
32
  node packages/harness/provisioner/bin/infer-provision.mjs all
27
- node packages/harness/provisioner/bin/infer-provision.mjs profile inference
33
+ node packages/harness/provisioner/bin/infer-provision.mjs profile melite-test
28
34
  node packages/harness/provisioner/bin/infer-provision.mjs profile headless --dsh-home /tmp/melite-dsh
29
35
  ```
30
36
 
@@ -63,3 +69,29 @@ pnpm run lint
63
69
  The profile renderer and its package links are the source of truth for profile
64
70
  membership; the current documentation index points here for the operator
65
71
  workflow.
72
+
73
+ ## Model Experience
74
+
75
+ ### Request context and condition
76
+
77
+ #### What the model sees
78
+
79
+ None — the package contributes no model-visible input; it renders profiles
80
+ and mounts rows, and whatever reaches a model request arrives through the
81
+ mounted tool plane, not this package.
82
+
83
+ #### Token effect
84
+
85
+ Zero-direct token effect.
86
+
87
+ #### KV Cache effect
88
+
89
+ Independent — no conversation state.
90
+
91
+ ## Known Limitations and Deferred Work
92
+
93
+ - **Rendered profiles are machine-local state** — they are not a source of
94
+ truth; re-render after manifest or workspace changes.
95
+ - **Headless selection is render-time** — `MELITE_HEADLESS` picks the
96
+ delegated run interface per profile; there is no per-request interface
97
+ switch.
@@ -3,8 +3,10 @@
3
3
  * infer-provision — profile provisioner for vanilla DSH installs.
4
4
  *
5
5
  * The bundle patch (cordis.patch.yml) covers the ROWS; this bin covers
6
- * the PROFILES: renders ready-to-boot profile dirs (inference /
7
- * headless / estate / melite-test) and installs their link deps.
6
+ * the PROFILES: renders ready-to-boot profile dirs (melite-test /
7
+ * headless / estate) and installs their link deps. Every render
8
+ * stamps render-provenance.json (F5: who/what/when/flags) and holds
9
+ * the profile's render lock across render+install (F6).
8
10
  * Self-locating — every path derives from this file's own location
9
11
  * (the provisioner package and its siblings), so a family re-split
10
12
  * or repo move changes nothing.
@@ -14,17 +16,29 @@
14
16
  * infer-provision profile <name> # render one profile dir
15
17
  * infer-provision preset # install the agent preset
16
18
  * Flags:
17
- * --dsh-home <dir> default $DSH_HOME || ~/.dsh
19
+ * --dsh-home <dir> default $DSH_HOME || ~/.dsh
20
+ * --estate-agent-route <p/m> estate agent brain, "provider/model"
21
+ * --estate-state-root <dir> override the estate row's stateRoot
22
+ * (walls render hermetic roots through
23
+ * config — never an env redirect)
24
+ * --profile-rows <file> operator rows (llm providers…), verbatim
18
25
  *
19
- * Rendered profiles mount the SAME rows the bundle patch mounts (the
20
- * profile's cordis.patch.yml user layer stays EMPTY by design — the
21
- * bundle owns the rows, so `dsh plugin add` and rendered profiles can
22
- * never drift apart). The template pins: our bundle + the dsh web base
23
- * for 'inference', the plain headless base for 'headless'.
26
+ * Rendered profiles mount the SAME melite rows the bundle patch mounts
27
+ * (the bundle owns the rows, so `dsh plugin add` and rendered profiles
28
+ * can never drift apart). The PROFILE layer (this dir's cordis.patch.yml)
29
+ * owns what no bundle can know: the estate row's seat config — repoRoot
30
+ * always (mechanical: this render's own repo root; the queue seats refuse
31
+ * an unpinned boot loud by design) and agentRoute when the flag names a
32
+ * brain — plus any operator rows spliced verbatim by --profile-rows
33
+ * (llm provider rows ride the profile layer the same way the web
34
+ * profile's own patch carries them; the file is never re-serialized).
35
+ * The template pins: our bundle + the dsh web base for 'melite-test',
36
+ * the plain headless base for 'headless'.
24
37
  */
25
38
 
26
39
  import { execSync } from 'node:child_process'
27
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
40
+ import { createHash } from 'node:crypto'
41
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
28
42
  import { dirname, join, resolve } from 'node:path'
29
43
  import { fileURLToPath } from 'node:url'
30
44
  import process from 'node:process'
@@ -40,14 +54,117 @@ const flag = (name, dflt) => {
40
54
  return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt
41
55
  }
42
56
  const DSH_HOME = flag('--dsh-home', process.env.DSH_HOME || join(process.env.HOME || '~', '.dsh'))
43
- const PROFILE = argv[1] && !argv[1].startsWith('--') ? argv[1] : 'inference'
57
+ const PROFILE = argv[1] && !argv[1].startsWith('--') ? argv[1] : 'melite-test'
58
+ const ESTATE_AGENT_ROUTE = flag('--estate-agent-route', '')
59
+ const ESTATE_STATE_ROOT = flag('--estate-state-root', '')
60
+ const PROFILE_ROWS = flag('--profile-rows', '')
61
+
62
+
63
+ // files IT rendered. A package.json with no provenance stamp naming
64
+ // this renderer (a hand-composed profile, another tool's output)
65
+
66
+ // original gone. See the guard at the package.json write.
67
+ const FORCE_RENDER = argv.includes('--force-render')
44
68
 
45
69
  const log = (...a) => console.log(...a)
46
70
  const die = (msg) => { console.error('infer-provision: ' + msg); process.exit(1) }
47
71
 
72
+ // ── F6 guards: atomic writes, the render lock, the install ceiling ──
73
+ // ATOMIC WRITES: the renderer owns its files wholesale but wrote
74
+ // them direct — a crash mid-render left a half-written profile that
75
+ // boots strange. Temp + rename per file (J6's writeJsonAtomic idiom,
76
+ // local: this bin is self-contained, no workspace imports). The tmp
77
+ // name is FIXED per file (never pid-suffixed): a killed run's
78
+ // leftover is overwritten by the next render, never accumulated —
79
+ // and the render lock below serializes renders, so no two writers
80
+ // share a tmp.
81
+ function writeFileAtomic(path, content) {
82
+ writeFileSync(path + '.tmp', content)
83
+ renameSync(path + '.tmp', path)
84
+ }
85
+
86
+ // THE RENDER LOCK: two renders (two `all` runs, a render racing a
87
+ // re-render) interleaved package.json and the patch row by row —
88
+ // both callers rendered-then-installed with no mutual exclusion.
89
+ // One lock file per profile dir, exclusive-create: a live holder
90
+ // refuses loud, a stale one (dead pid) is taken with a warning.
91
+ const LOCK_NAME = '.render.lock'
92
+ function acquireLock(dst) {
93
+ const lock = join(dst, LOCK_NAME)
94
+ const take = () => writeFileSync(lock, String(process.pid) + '\n', { flag: 'wx' })
95
+ try {
96
+ take()
97
+ return
98
+ } catch (err) {
99
+ if (err === null || typeof err !== 'object' || err.code !== 'EEXIST') throw err
100
+ }
101
+ const prev = readFileSync(lock, 'utf8').split('\n')[0]
102
+ const pid = Number(prev)
103
+ let alive = false
104
+ if (Number.isInteger(pid) && pid > 0) {
105
+ try { process.kill(pid, 0); alive = true } catch { /* dead */ }
106
+ }
107
+ if (alive) die(`profile dir ${dst} is already being rendered (lock ${lock}, holder pid ${pid}) — refusing a concurrent render`)
108
+ log(` stale render lock (holder pid ${prev.trim() || '<unparsed>'} dead) — taking it`)
109
+ try { unlinkSync(lock) } catch { /* raced away */ }
110
+ try {
111
+ take()
112
+ } catch {
113
+ die(`profile dir ${dst} lost a lock race (${lock}) — re-run the render`)
114
+ }
115
+ }
116
+ function releaseLock(dst) {
117
+ try { unlinkSync(join(dst, LOCK_NAME)) } catch { /* never held / already gone */ }
118
+ }
119
+
120
+ // PROVENANCE (provisioner audit F5): a rendered profile dir is an
121
+ // artifact with no memory — when a boot misbehaves nothing says
122
+ // which provisioner, which tree, which flags laid it. Every render
123
+ // writes render-provenance.json beside the profile files:
124
+ // machine-readable, DSH-loader-inert (a data file, never a config
125
+ // plane — the loader reads package.json/cordis.* only), the
126
+ // fossil-vs-fresh question answered without archaeology.
127
+ function provenanceDoc(name, provPkg, bundles, profileRowsSha) {
128
+ const root = resolve(PROVISIONER, '../../..')
129
+ let commit = null
130
+ let dirty = null
131
+ try {
132
+ const sha = execSync('git rev-parse HEAD', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
133
+ commit = /^[0-9a-f]{40}$/.test(sha) ? sha : null
134
+ if (commit !== null) {
135
+ dirty = execSync('git status --porcelain', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() !== ''
136
+ }
137
+ } catch { /* not a git checkout — the stamp says so honestly (nulls) */ }
138
+ return {
139
+ renderedAt: new Date().toISOString(),
140
+ renderer: 'infer-provision',
141
+ provisioner: { name: provPkg.name, version: provPkg.version },
142
+ source: { root, commit, dirty },
143
+ profile: name,
144
+ bundles: [...bundles],
145
+ flags: {
146
+ estateAgentRoute: ESTATE_AGENT_ROUTE || null,
147
+ estateStateRoot: ESTATE_STATE_ROOT || null,
148
+ profileRows: PROFILE_ROWS ? { path: PROFILE_ROWS, sha256: profileRowsSha } : null,
149
+ },
150
+ }
151
+ }
152
+
153
+ // agentRoute wears the estate row's own shape (packages/web/melite-estate
154
+ // src/index.ts): "provider/model" — the same regex the seat applies. A
155
+ // malformed route dies HERE, at the earliest resolvable point, never as a
156
+ // silently-unseated engine three boots later.
157
+ if (ESTATE_AGENT_ROUTE && !/^([^/\s]+)\/(.+)$/.test(ESTATE_AGENT_ROUTE)) {
158
+ die('--estate-agent-route wants provider/model (e.g. opencode-go/muse-spark-1.3-contributor), got "' + ESTATE_AGENT_ROUTE + '"')
159
+ }
160
+
48
161
  // ── profile rendering ───────────────────────────────────────────────────
49
162
  const PROFILE_BASES = {
50
- // inference = the studio: web app base + the SELF-REGISTERING
163
+ // melite-test = THE studio/test lane, ONE name (single-setup law
164
+
165
+ // two names for one mount, and the README + runbook pointed at
166
+ // different ones. `inference` is gone; this base is the only web
167
+ // mount): web app base + the SELF-REGISTERING
51
168
 
52
169
  // its existence row in ITS OWN package — the provisioner stopped
53
170
 
@@ -59,9 +176,38 @@ const PROFILE_BASES = {
59
176
  // @melite/card-base/client for the browser graph — every card
60
177
  // client externalizes it, and without the row the loader's
61
178
  // require misses the module table at web boot.
62
- inference: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@melite/harness-provisioner',
63
- '@melite/harness-tools', '@melite/card-base', '@melite-plugin/dsh-card-view', '@melite-plugin/dsh-card-deck',
64
- '@melite-plugin/dsh-gallery'],
179
+ // three-client rides the WEB base for the SAME law (the :3088 boot
180
+
181
+ // externalize @melite/three-client/client, and three-client's own
182
+ // patch is the ONLY row that materializes it ("bundling
183
+ // @melite/three-client in a profile applies this patch" — the
184
+ // package's own contract). Without the bundle, four plugins die
185
+ // at boot with `missed the module table` and the deck pends on
186
+ // remote.manage behind them.
187
+ // dsh-estate rides the web base too (the :3088 browser census,
188
+
189
+ // as child plugins — drafts (bench/subscribe), models (the
190
+ // /v1/model-catalog discovery door), media, queue, runs, manage.
191
+ // Without it the studio lane BOOTS CLEAN but serves a husk: the
192
+ // bench spotlight stream and the catalog toast 500 in the browser
193
+ // console while the boot log stays silent — the same
194
+ // seed-has-it-base-does-not drift as three-client.
195
+ // the two card STUDIOS ride the web base (the H3 bar autopsy,
196
+
197
+ // own their rows in their OWN bundle patches, and this bundle's
198
+ // patch carries no twin ("NO row here — the profile bundles it",
199
+ // cordis.patch.yml) — so while no base mounted them they were
200
+ // deps with no door: installed by the manifest mirror above, rows
201
+ // mounted by nothing, client entries in no browser graph. The H3
202
+ // card then rendered the generic form — NO timeline bar — with
203
+ // every wall green (the e2e seed mounts both; the live base did
204
+ // not). @melite/3d-img-to-mesh needs no bundle row: this bundle's
205
+ // own card-studio-img-to-mesh row mounts it, and a bundle would be
206
+ // the twin-row crash law.
207
+ 'melite-test': ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@melite/harness-provisioner',
208
+ '@melite/harness-tools', '@melite-plugin/dsh-estate', '@melite/card-base', '@melite/three-client',
209
+ '@melite-plugin/dsh-card-view', '@melite-plugin/dsh-card-deck', '@melite-plugin/dsh-gallery',
210
+ '@melite/video-h3-timeline', '@melite/motion-motion', '@melite/image-input-prep'],
65
211
  // headless = the agent server: one-shot task runner + the tools
66
212
  // plane (harness-tools self-registers its melite-tools row; the
67
213
  // renderer disables the web-plane doors row in this mount).
@@ -81,16 +227,6 @@ const PROFILE_BASES = {
81
227
  // tools row's HEADLESS interface by its self-registration id.
82
228
  estate: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app', '@melite/harness-provisioner',
83
229
  '@melite/harness-tools', '@melite-plugin/dsh-estate'],
84
- // melite-test = THE TEST LANE, RE-COMPOSED (REBUILD-20, gateway
85
-
86
- // melite-test profile mounting the estate API as a SECOND
87
- // process on :33081) died with the gateway. Its replacement is
88
- // the SAME row set as the studio — web app base + the estate
89
- // rows in ONE process. Custom bakes serve from this name; the
90
- // :33081 wire never existed here.
91
- 'melite-test': ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@melite/harness-provisioner',
92
- '@melite/harness-tools', '@melite/card-base', '@melite-plugin/dsh-card-view', '@melite-plugin/dsh-card-deck',
93
- '@melite-plugin/dsh-gallery'],
94
230
  }
95
231
 
96
232
  /** Maps every workspace package's name to its directory by reading
@@ -138,7 +274,14 @@ function workspacePackages() {
138
274
  function renderProfile(name) { // In-box bundles (@deepseek-ai/*) resolve from the DSH INSTALL, never
139
275
  // from a registry - only OUR link goes in dependencies. Mirrors what
140
276
  // `dsh plugin` itself initializes (PROFILE_TEMPLATES + reconcile).
141
- const bundles = PROFILE_BASES[name] || PROFILE_BASES.headless
277
+
278
+ // old `|| PROFILE_BASES.headless` fallback turned a typo into a
279
+ // bootable-looking directory with the wrong rows and exit 0.
280
+ if (!(name in PROFILE_BASES)) {
281
+ console.error(`infer-provision: unknown profile '${name}' — known: ${Object.keys(PROFILE_BASES).join(', ')}`)
282
+ process.exit(1)
283
+ }
284
+ const bundles = PROFILE_BASES[name]
142
285
  const dst = join(DSH_HOME, 'profiles', name)
143
286
  mkdirSync(dst, { recursive: true })
144
287
  const link = 'link:' + PROVISIONER
@@ -170,10 +313,45 @@ function renderProfile(name) { // In-box bundles (@deepseek-ai/*) resolve from
170
313
  // the provisioner is linked, its install resolves it in the repo
171
314
  // workspace.
172
315
  }
316
+
317
+ // non-in-box bundle the profile base mounts ALSO becomes a direct
318
+ // link: dep — the F5 comment's own intent above ("every plugin
319
+ // package the bundle rows name must become a DIRECT link: dep"),
320
+ // which the mirror-the-manifest loop never implemented for bundles
321
+ // outside the provisioner's own manifest (@melite/card-base rode
322
+ // the inference/melite-test bases with no installed package, and
323
+
324
+ // @deepseek-ai/* bundles resolve from the DSH install, never here;
325
+ // any other unresolvable bundle fails the RENDER loud, never a
326
+ // boot three steps later.
327
+ const bundleDeps = {}
328
+ for (const b of bundles) {
329
+ if (b.startsWith('@deepseek-ai/')) continue
330
+ if (!byName.has(b)) die(`profile base mounts ${b} but no workspace package carries that name — refusing an unresolvable bundle`)
331
+ bundleDeps[b] = 'link:' + byName.get(b)
332
+ }
173
333
 
174
334
  const pkgPath = join(dst, 'package.json')
175
335
  let pkg = { name: 'dsh-profile-' + name, private: true, dependencies: {} }
176
336
  if (existsSync(pkgPath)) {
337
+ // F1 refuse-or-backup: without a stamp naming THIS renderer the
338
+ // render dies loud; --force-render backs the file up and proceeds.
339
+ // A file with our own stamp re-renders freely — the no-drift law
340
+ // (the renderer owns its output wholesale) is unchanged.
341
+ if (!FORCE_RENDER) {
342
+ let mine = false
343
+ try {
344
+ const prov = JSON.parse(readFileSync(join(dst, 'render-provenance.json'), 'utf8'))
345
+ mine = prov !== null && typeof prov === 'object' && prov.renderer === 'infer-provision'
346
+ } catch { /* no stamp, or unparseable: not provably ours */ }
347
+ if (!mine) {
348
+ die('refusing to overwrite ' + pkgPath + ' — no render-provenance.json names this renderer '
349
+ + '(a hand-composed or foreign file). Move it aside, or pass --force-render to back it up '
350
+ + 'as package.json.prerender.bak and re-render')
351
+ }
352
+ } else {
353
+ copyFileSync(pkgPath, pkgPath + '.prerender.bak')
354
+ }
177
355
  try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { /* re-render */ }
178
356
  }
179
357
  pkg.name = pkg.name || 'dsh-profile-' + name
@@ -185,9 +363,10 @@ function renderProfile(name) { // In-box bundles (@deepseek-ai/*) resolve from
185
363
  // and pnpm warned "installing a dependency from a non-existent
186
364
  // directory" on every install while the profile silently carried
187
365
 
188
- // on every render; user-added registry deps belong in the bundle, not
189
- // here.
190
- pkg.dependencies = { ...rowDeps, '@melite/harness-provisioner': link }
366
+ // on every render; bundleDeps cover every mounted non-in-box bundle
367
+ // (the coverage cure above); user-added registry deps belong in the
368
+ // bundle, not here.
369
+ pkg.dependencies = { ...bundleDeps, ...rowDeps, '@melite/harness-provisioner': link }
191
370
 
192
371
  // twin rows for the self-registering packages (channel law
193
372
 
@@ -203,57 +382,173 @@ function renderProfile(name) { // In-box bundles (@deepseek-ai/*) resolve from
203
382
  // the bases). The bases OWN this list wholesale; a retired
204
383
  // package leaves the profile the day it leaves PROFILE_BASES.
205
384
  pkg.dsh = { profile: { bundles: [...new Set(bundles)] } }
206
- writeFileSync(join(dst, 'package.json'), JSON.stringify(pkg, null, 2) + String.fromCharCode(10))
207
- // user layer stays EMPTY in web mounts — the bundle patch owns every
208
- // row (no drift). NON-web mounts (headless/SDK) get the web-surface
209
- // rows mechanically DISABLED (by the packages' SELF-REGISTRATION
385
+ writeFileAtomic(join(dst, 'package.json'), JSON.stringify(pkg, null, 2) + String.fromCharCode(10))
386
+ // The cordis.yml USER layer stays EMPTY — the bundle patch owns every
387
+ // melite row (no drift). NON-web mounts (headless/SDK) get the
388
+ // web-surface rows mechanically DISABLED in the profile patch (by the
210
389
 
211
- // The bundle owns the rows; the renderer owns the per-app mount
212
- // shape.
390
+ // exists to activate them. The bundle owns the rows; the renderer
391
+ // owns the per-app mount shape and the seat pins below.
213
392
  const webMount = bundles.includes('@deepseek-ai/dsh-web-app')
214
393
  // melite-tools-doors is web-profiles-only by its own contract (the
215
394
  // ✦ doors need webServer+llm — absent in sdk/headless mounts); the
216
395
  // renderer disables it with the other web rows in non-web mounts.
217
396
  const webRows = ['melite-tools-doors',
218
397
  'infer-card-view', 'ray-card-deck', 'infer-gallery']
219
- writeFileSync(join(dst, 'cordis.patch.yml'),
398
+
399
+ // decided seating is retired — the estate row's CONFIG owns it. Every
400
+ // profile mounting dsh-estate carries repoRoot here: mechanical (this
401
+ // render's own repo root), so the runs/drafts seats never boot
402
+ // unpinned (they refuse loud by design). agentRoute names the agent
403
+ // brain (provider/model); absent, the estate logs the engine unseated
404
+ // — the honest state, never a silent default. acquireStallTimeoutMs
405
+
406
+ // the lane rides a pinned 10s, never the library default by accident.
407
+
408
+ // downloads-stream audit): the lane is the long-lived process that
409
+ // owns fetch lifetimes — its durable claim ticker claims requested
410
+ // rows and stamps the claimer heartbeat, so a headless press's
411
+ // wanted download survives its own session dying. A non-web mount
412
+ // (headless task, estate job server) is short-lived: a claimer in
413
+ // it would orphan every fetch it died mid-flight, so its presses
414
+ // take their own wire, exactly as before.
415
+ const estateRow = bundles.includes('@melite-plugin/dsh-estate')
416
+ ? '# ESTATE SEAT PIN — the estate row config owns every seat:\n'
417
+ + '# repoRoot pins the runs/drafts seats (unpinned boots refuse loud);\n'
418
+ + '# stateRoot overrides it (J12: walls render hermetic roots by\n'
419
+ + '# config — stateRoot wins over repoRoot in the queue seat).\n'
420
+ + '# agentRoute names the agent brain (absent = unseated, logged loud).\n'
421
+ + '# acquireStallTimeoutMs: the deliberate stall budget (audit F7).\n'
422
+ + (webMount
423
+ ? '# durableDownloads: the lane claims requested rows (the lifetime\n'
424
+ + '# finding) — long-lived web mounts only, never a task process.\n'
425
+ : '')
426
+ + '- id: melite-estate\n'
427
+ + ' config:\n'
428
+ + ' repoRoot: ' + JSON.stringify(resolve(PROVISIONER, '../../..')) + '\n'
429
+ + ' acquireStallTimeoutMs: 10000\n'
430
+ + (webMount ? ' durableDownloads: true\n' : '')
431
+ + (ESTATE_STATE_ROOT
432
+ ? ' stateRoot: ' + JSON.stringify(ESTATE_STATE_ROOT) + '\n'
433
+ : '')
434
+ + (ESTATE_AGENT_ROUTE
435
+ ? ' agentRoute: ' + JSON.stringify(ESTATE_AGENT_ROUTE) + '\n'
436
+ : '')
437
+ : ''
438
+ // OPERATOR ROWS (--profile-rows <file>): verbatim text splice, never
439
+ // re-serialized (the web profile's preset-row law). Llm provider rows
440
+ // ride the profile layer because the bundle patch owns MELITE rows
441
+ // only — the same home the web profile's own patch gives its
442
+ // providers. Rows apply by id; a row here never inserts a melite
443
+ // bundle row (the no-drift law holds — the ids belong to the
444
+ // operator's adapter mounts).
445
+ let operatorRows = ''
446
+ let profileRowsSha = null
447
+ if (PROFILE_ROWS) {
448
+ if (!existsSync(PROFILE_ROWS)) die('--profile-rows: no such file: ' + PROFILE_ROWS)
449
+ const text = readFileSync(PROFILE_ROWS, 'utf8')
450
+ profileRowsSha = createHash('sha256').update(text, 'utf8').digest('hex')
451
+ const firstRow = text.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('#'))
452
+ if (firstRow === undefined || !firstRow.startsWith('- ')) {
453
+ die('--profile-rows: ' + PROFILE_ROWS + ' is not a YAML row list (the first non-comment line must start with "- ")')
454
+ }
455
+ operatorRows = '# operator rows (verbatim splice: ' + PROFILE_ROWS + ')\n'
456
+ + text + (text.endsWith('\n') ? '' : '\n')
457
+ }
458
+ writeFileAtomic(join(dst, 'cordis.patch.yml'),
220
459
  webMount
221
- ? '# rendered by infer-provision - rows live in the bundle' + '\n' + '[]' + '\n'
222
- : '# rendered by infer-provision - rows live in the bundle.\n'
460
+ ? '# rendered by infer-provision - melite rows live in the bundle;\n'
461
+ + '# the rows below are the profile layer (seat pin + operator rows).\n'
462
+ + (estateRow + operatorRows || '[]\n')
463
+ : '# rendered by infer-provision - melite rows live in the bundle.\n'
223
464
  + '# No webServer service in this mount: the four web-surface rows\n'
224
465
  + '# are disabled so activation cannot pend forever.\n'
225
466
  + webRows.map(id => '- id: ' + id + '\n disabled: true\n').join('')
226
- // ESTATE JOB SERVER (name === 'estate'): this mount IS the
227
- // delegated operator — the tools row selects the HEADLESS
228
- // interface (the melite_flow + run_status run doors). Pinned
229
- // in the render (not ambient env) because the interface is
230
- // profile state: browser mounts compose, the job server
231
- // executes. Every other mount keeps the browser default.
232
- + (name === 'estate'
467
+ + estateRow + operatorRows
468
+ // DELEGATED-OPERATOR MOUNTS (estate | headless): this mount
469
+ // RUNS jobs — the tools row selects the HEADLESS interface
470
+ // (the melite_flow + run_status run doors). Pinned in the
471
+ // render (not ambient env) because the interface is profile
472
+ // state: browser mounts compose, the job server executes.
473
+ // The 'headless' agent server rides the same pin — its base
474
+ // list promises melite_flow ("a headless task that composes
475
+ // media reaches the same runs/queue/drafts seats"), and the
476
+
477
+ // compose-only mount: benches composed and validated, every
478
+ // execution refused at the absent verb. Every other mount
479
+ // keeps the browser default.
480
+ + (name === 'estate' || name === 'headless'
233
481
  ? '# HEADLESS interface: the delegated operator runs jobs here.\n'
234
482
  + '- id: melite-tools\n config:\n headless: true\n'
235
483
  : ''))
236
- writeFileSync(join(dst, 'cordis.yml'), '# rendered by infer-provision' + '\n' + '[]' + '\n')
484
+ writeFileAtomic(join(dst, 'cordis.yml'), '# rendered by infer-provision' + '\n' + '[]' + '\n')
237
485
  const ws = join(dst, 'pnpm-workspace.yaml')
238
- if (!existsSync(ws)) writeFileSync(ws, 'packages:' + '\n' + ' - node_modules/*' + '\n')
486
+ if (!existsSync(ws)) writeFileAtomic(ws, 'packages:' + '\n' + ' - node_modules/*' + '\n')
487
+ // F5 — the provenance stamp (see provenanceDoc): fossil-vs-fresh,
488
+ // answered in one read.
489
+ writeFileAtomic(join(dst, 'render-provenance.json'),
490
+ JSON.stringify(provenanceDoc(name, provPkg, bundles, profileRowsSha), null, 2) + '\n')
239
491
 
240
492
  log('rendered profile :', dst)
241
493
  log(' bundles :', bundles.join(', '))
242
494
  return dst
243
495
  }
244
496
 
497
+ // INSTALL GUARDS (F6): the install ran with no timeout (a hung
498
+ // registry stalled forever), no catch (a failed install threw a raw
499
+ // stack with no manual-command hint), and both callers ignored the
500
+ // `false` return (missing-pnpm printed a warning, then the boot
501
+ // lines printed as if everything were installed). Now: a 10-minute
502
+ // ceiling (link-only installs take seconds; a cold-registry fetch
503
+ // takes minutes; a hang dies loud, never stalls CI forever) and
504
+ // every failure logged loud with the manual command. The boolean is
505
+ // HONORED by both callers through renderInstall below.
506
+ const INSTALL_TIMEOUT_MS = 600_000
245
507
  function installDeps(profileDir) {
246
508
  const pm = execSync('command -v pnpm || true').toString().trim()
247
509
  || join(process.env.HOME || '', '.local/share/pnpm/pnpm')
248
510
  if (!existsSync(pm)) {
249
- log(' WARNING: pnpm not found — run manually: (cd ' + profileDir + ' && pnpm install)')
511
+ console.error('infer-provision: pnpm not found — deps NOT installed for ' + profileDir
512
+ + '; run manually: (cd ' + profileDir + ' && pnpm install)')
250
513
  return false
251
514
  }
252
515
  log(' installing deps with', pm, '…')
253
- execSync('"' + pm + '" install', { cwd: profileDir, stdio: 'inherit' })
516
+ try {
517
+ execSync('"' + pm + '" install', { cwd: profileDir, stdio: 'inherit', timeout: INSTALL_TIMEOUT_MS })
518
+ } catch (err) {
519
+ const e = (err !== null && typeof err === 'object') ? err : {}
520
+ const why = e.code === 'ETIMEDOUT'
521
+ ? 'timed out after ' + String(INSTALL_TIMEOUT_MS / 1000) + 's'
522
+ : 'failed (status ' + String(e.status ?? e.signal ?? e.code ?? 'unknown') + ') — output above'
523
+ console.error('infer-provision: pnpm install ' + why + ' in ' + profileDir
524
+ + '; fix the failure and re-run, or install manually: (cd ' + profileDir + ' && pnpm install)')
525
+ return false
526
+ }
254
527
  return true
255
528
  }
256
529
 
530
+ // RENDER+INSTALL, LOCKED (F6): the one door both commands use — the
531
+ // lock covers the render AND the install (an install reading
532
+ // package.json mid-render is the same race). The install's boolean
533
+ // is honored HERE, and the die sits AFTER the finally on purpose:
534
+ // process.exit skips finally blocks, so a die inside the try would
535
+ // leak the lock forever.
536
+ function renderInstall(name) {
537
+ const dst = join(DSH_HOME, 'profiles', name)
538
+ mkdirSync(dst, { recursive: true })
539
+ acquireLock(dst)
540
+ let dir = dst
541
+ let ok = false
542
+ try {
543
+ dir = renderProfile(name)
544
+ ok = installDeps(dir)
545
+ } finally {
546
+ releaseLock(dst)
547
+ }
548
+ if (!ok) die(`profile '${name}' rendered but deps NOT installed — boot would fail at bundle resolution; run manually: (cd ${dir} && pnpm install)`)
549
+ return dir
550
+ }
551
+
257
552
  // ── main ────────────────────────────────────────────────────────────────
258
553
  switch (cmd) {
259
554
  case 'all': {
@@ -264,8 +559,8 @@ switch (cmd) {
264
559
  // command renders + installs every profile (the gateway health
265
560
  // check died with the gateway — there is no second process to
266
561
  // probe anymore).
267
- for (const name of ['inference', 'headless', 'estate', 'melite-test']) {
268
- installDeps(renderProfile(name))
562
+ for (const name of ['melite-test', 'headless', 'estate']) {
563
+ renderInstall(name)
269
564
  }
270
565
  log('boot with:')
271
566
  log(' LANES BOOT ONLY THROUGH THE PROVISION (the lane law, li-bu):')
@@ -273,15 +568,14 @@ switch (cmd) {
273
568
  log(' (this command installs the profiles; the sibling provisioner owns the')
274
569
  log(' launch — binary pin, version floor, home, and unit. The shapes below')
275
570
  log(' are what the door runs UNDER THE HOOD, never a hand-run shortcut:)')
276
- log(' dsh --profile inference # the studio GUI')
571
+ log(' dsh --profile melite-test # the studio GUI + TEST lane (one process, no :33081)')
277
572
  log(' dsh --profile headless "<task>" # one-shot agent with card tools')
278
573
  log(' dsh --profile estate # the SDK job server (estate row)')
279
- log(' dsh --profile melite-test # the TEST lane (one process, no :33081)')
280
574
  break
281
575
  }
282
576
  case 'profile': {
283
- installDeps(renderProfile(PROFILE))
284
- log('boot with: dsh --profile ' + PROFILE + (PROFILE === 'inference' ? '' : ' "<task>"'))
577
+ renderInstall(PROFILE)
578
+ log('boot with: dsh --profile ' + PROFILE + (PROFILE === 'melite-test' ? '' : ' "<task>"'))
285
579
  break
286
580
  }
287
581
  // the `preset` arm died with installPreset (undefined since the
package/cordis.patch.yml CHANGED
@@ -23,8 +23,13 @@
23
23
  # owning DSH cross-host).
24
24
  #
25
25
  # All rows resolve BY PACKAGE NAME through this bundle's own link:
26
- # dependencies (pnpm workspace links back into the repo) — never baked
27
- # absolute paths, so the repo can move without re-provisioning.
26
+ # dependencies. THE RENDER PATH BAKES ABSOLUTE PATHS (the bin's
27
+ # renderProfile resolves every link: against the checkout at render
28
+
29
+ # renamed checkout needs a re-provision, and a stale profile
30
+ # carries dead links pnpm warns about. Rows THIS patch serves to an
31
+ # installed profile (via the bundle's own dependency table) stay
32
+ # name-resolved.
28
33
  #
29
34
 
30
35
  # is the ONLY app — there is no second process, no :33081, no
@@ -85,16 +90,21 @@
85
90
  # ride the packages' dsh.client declarations (src/client ->
86
91
  # lib/client.js).
87
92
  #
88
- # card-studio-anny: NO row here — @melite/3d-anny self-registers
89
- # (anny-studio, its own cordis.patch.yml, 6a16cfc9); a twin row
90
- # double-mounted the package in every profile bundling it
93
+ # card-studio-anny: NO row here, and nothing to mount either —
94
+ # @melite/3d-anny's studio is RETIRED (stroke 89): the package
95
+ # ships no client face and no cordis.patch.yml, and its bundle
96
+ # left the web bases (bin/infer-provision.mjs, the stroke-89
97
+ # note). A row here would mount a package that composes
98
+ # nothing. Should a self-registering anny studio ever return, it
99
+ # comes back as a mounted bundle in the bases, never as a twin
91
100
 
92
- # profile (their self-patch never applies there).
101
+ # for studios not bundled by the profile (their self-patch never
102
+ # applies there).
93
103
 
94
104
  # card-studio-h3-timeline: NO row here — @melite/video-h3-timeline
95
105
  # self-registers (its own cordis.patch.yml); a twin row here
96
106
  # double-mounted the package in every profile bundling both
97
- # (same crash law as card-studio-anny).
107
+
98
108
 
99
109
  # card-studio-ideogram4 RETIRED with its card (the form-seams
100
110
 
@@ -111,7 +121,7 @@
111
121
  # card-studio-motion: NO row here — @melite/motion-motion
112
122
  # self-registers (its own cordis.patch.yml); a twin row here
113
123
  # double-mounted the package in every profile bundling both
114
- # (same crash law as card-studio-anny).
124
+
115
125
 
116
126
 
117
127