dsh-plugin-local-infer 0.1.0 → 0.1.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.
Files changed (73) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +8 -1
  3. package/cordis.patch.yml +6 -6
  4. package/docs/screenshot-20261014.png +0 -0
  5. package/docs/zh/README.md +100 -0
  6. package/dsh/client.js +644 -143
  7. package/dsh/host.js +1 -1
  8. package/package.json +14 -11
  9. package/packages/core/lib/adapter.d.ts +75 -4
  10. package/packages/core/lib/debug.d.ts +1 -1
  11. package/packages/core/lib/debug.js +1 -1
  12. package/packages/core/lib/index.d.ts +3 -2
  13. package/packages/core/lib/index.js +2 -1
  14. package/packages/core/lib/launch-command.d.ts +13 -0
  15. package/packages/core/lib/launch-command.js +64 -0
  16. package/packages/core/lib/models.d.ts +2 -1
  17. package/packages/core/lib/models.js +10 -1
  18. package/packages/core/lib/status.d.ts +9 -1
  19. package/packages/core/lib/status.js +6 -1
  20. package/packages/core/package.json +1 -1
  21. package/packages/providers/lib/acquire.d.ts +125 -0
  22. package/packages/providers/lib/acquire.js +325 -0
  23. package/packages/providers/lib/adapters.d.ts +43 -6
  24. package/packages/providers/lib/adapters.js +386 -58
  25. package/packages/providers/lib/debug.d.ts +1 -1
  26. package/packages/providers/lib/debug.js +1 -1
  27. package/packages/providers/lib/deps.d.ts +6 -4
  28. package/packages/providers/lib/deps.js +17 -3
  29. package/packages/providers/lib/engines.d.ts +6 -5
  30. package/packages/providers/lib/engines.js +12 -4
  31. package/packages/providers/lib/guidelines.d.ts +2 -2
  32. package/packages/providers/lib/guidelines.js +17 -1
  33. package/packages/providers/lib/hyperqwen.d.ts +2 -2
  34. package/packages/providers/lib/hyperqwen.js +1 -1
  35. package/packages/providers/lib/index.d.ts +10 -4
  36. package/packages/providers/lib/index.js +9 -5
  37. package/packages/providers/lib/infernix.d.ts +170 -0
  38. package/packages/providers/lib/infernix.js +671 -0
  39. package/packages/providers/lib/llamacpp.d.ts +2 -2
  40. package/packages/providers/lib/llamacpp.js +1 -1
  41. package/packages/providers/lib/model-defaults.d.ts +7 -5
  42. package/packages/providers/lib/model-defaults.js +28 -4
  43. package/packages/providers/lib/ninfer.d.ts +9 -2
  44. package/packages/providers/lib/ninfer.js +10 -3
  45. package/packages/providers/lib/platform.d.ts +31 -0
  46. package/packages/providers/lib/platform.js +58 -0
  47. package/packages/providers/lib/presets.d.ts +2 -2
  48. package/packages/providers/lib/presets.js +1 -1
  49. package/packages/providers/lib/strata.d.ts +2 -2
  50. package/packages/providers/lib/strata.js +1 -1
  51. package/packages/providers/lib/vram.d.ts +2 -2
  52. package/packages/providers/lib/vram.js +1 -1
  53. package/packages/providers/package.json +3 -3
  54. package/packages/service/lib/index.d.ts +63 -20
  55. package/packages/service/lib/index.js +279 -43
  56. package/packages/service/lib/types.d.ts +44 -2
  57. package/packages/service/package.json +2 -2
  58. package/packages/tool/lib/index.d.ts +1 -1
  59. package/packages/tool/lib/index.js +1 -1
  60. package/packages/tool/package.json +3 -3
  61. package/packages/ui/lib/deps.d.ts +2 -2
  62. package/packages/ui/lib/deps.js +1 -1
  63. package/packages/ui/lib/form.d.ts +2 -2
  64. package/packages/ui/lib/form.js +1 -1
  65. package/packages/ui/lib/index.d.ts +1 -1
  66. package/packages/ui/lib/index.js +1 -1
  67. package/packages/ui/lib/meter.d.ts +2 -2
  68. package/packages/ui/lib/meter.js +1 -1
  69. package/packages/ui/lib/route.d.ts +84 -13
  70. package/packages/ui/lib/route.js +496 -56
  71. package/packages/ui/lib/warnings.d.ts +2 -2
  72. package/packages/ui/lib/warnings.js +1 -1
  73. package/packages/ui/package.json +5 -5
@@ -12,7 +12,7 @@
12
12
  * ~/.dsh/local-infer/config.json, shared with the CLI, so the browser never
13
13
  * sees a secret it did not store. The routes are held by an effect, so they
14
14
  * go when the plugin does.
15
- * @module @dsh/dsh-plugin-local-infer/ui/route
15
+ * @module dsh-plugin-local-infer/ui/route
16
16
  */
17
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
@@ -20,8 +20,8 @@ import { dirname, join } from 'node:path';
20
20
  // Relative, not the root package subpath: a self-referencing bare specifier
21
21
  // only resolves when the package sits inside a node_modules tree, so a
22
22
  // local-path or pnpm-linked install of this repo would fail the import.
23
- import { CHAT_TEMPLATE_FILE, buildModelEntry, checkDeps, DEFAULT_LOG_PATH_TEMPLATE, hfChatTemplateCommand, hfDownloadCommand, launchRecordFromPlan, modelArtifactPath, modelEntriesForEngine, providerIdFor, renderInstallString, resolveLogPath, } from '../../core/lib/index.js';
24
- import { CHAT_TEMPLATE_OPTIONS, defaultModelFor, defaultModelsForEngine, ENGINES, } from '../../providers/lib/index.js';
23
+ import { CHAT_TEMPLATE_FILE, buildModelEntry, checkDeps, DEFAULT_LOG_PATH_TEMPLATE, formatLaunchCommand, hfChatTemplateCommand, hfDownloadCommand, launchRecordFromPlan, modelArtifactPath, modelEntriesForEngine, providerIdFor, renderInstallString, resolveLogPath, } from '../../core/lib/index.js';
24
+ import { CHAT_TEMPLATE_OPTIONS, buildEngineRepo, defaultModelFor, defaultModelsForEngine, ENGINES, enginePlatformDefaults, infernixGetDownloads, nodeLifecycleHost, syncEngineRepo, } from '../../providers/lib/index.js';
25
25
  import { renderDeps } from "./deps.js";
26
26
  import { renderForm } from "./form.js";
27
27
  import { renderMeter } from "./meter.js";
@@ -241,7 +241,16 @@ function parsePlatformConfigs(raw) {
241
241
  if (typeof fields.engineFork === 'string') {
242
242
  stored.engineFork = fields.engineFork;
243
243
  }
244
- if (stored.engineDirectory !== undefined || stored.engineFork !== undefined) {
244
+ if (typeof fields.buildCommands === 'string') {
245
+ stored.buildCommands = fields.buildCommands;
246
+ }
247
+ if (typeof fields.dockerTag === 'string') {
248
+ stored.dockerTag = fields.dockerTag;
249
+ }
250
+ if (stored.engineDirectory !== undefined ||
251
+ stored.engineFork !== undefined ||
252
+ stored.buildCommands !== undefined ||
253
+ stored.dockerTag !== undefined) {
245
254
  platform[engineId] = stored;
246
255
  }
247
256
  }
@@ -265,11 +274,55 @@ function parseStoredPlatformConfig(raw) {
265
274
  if (typeof fields.engineFork === 'string') {
266
275
  stored.engineFork = fields.engineFork;
267
276
  }
268
- if (stored.engineDirectory === undefined && stored.engineFork === undefined) {
277
+ if (typeof fields.buildCommands === 'string') {
278
+ stored.buildCommands = fields.buildCommands;
279
+ }
280
+ if (typeof fields.dockerTag === 'string') {
281
+ stored.dockerTag = fields.dockerTag;
282
+ }
283
+ if (stored.engineDirectory === undefined &&
284
+ stored.engineFork === undefined &&
285
+ stored.buildCommands === undefined &&
286
+ stored.dockerTag === undefined) {
269
287
  return undefined;
270
288
  }
271
289
  return stored;
272
290
  }
291
+ /**
292
+ * relinkStoredProviders prices every stored provider launch record against the
293
+ * live process table through the service's re-link rule and returns the
294
+ * provider list the card should read: an adopted engine is re-marked plugin-
295
+ * launched and tied to its model by the service, and a liveness watcher starts
296
+ * on its port so a later pkill settles the stored state through the death
297
+ * observer without another summary read; a dead engine's record is cleared on
298
+ * disk here (the previous-pid list with it), so a recycled pid cannot re-link
299
+ * to a config whose engine is down. The route calls it on mount and the
300
+ * summary calls it per read, making re-attach independent of the card opening.
301
+ */
302
+ export async function relinkStoredProviders(localInfer, stored) {
303
+ const recorded = stored.providers.filter((provider) => provider.launch !== undefined);
304
+ if (recorded.length === 0)
305
+ return stored.providers;
306
+ const outcomes = await localInfer.reLinkProviders(recorded.map((provider) => ({ id: provider.id, record: provider.launch })));
307
+ if (!recorded.some((provider) => outcomes[provider.id] === 'dead')) {
308
+ return stored.providers;
309
+ }
310
+ const providers = stored.providers.map((provider) => {
311
+ if (provider.launch === undefined || outcomes[provider.id] !== 'dead') {
312
+ return provider;
313
+ }
314
+ // Rebuild the dead entry without the launch key, so the record (including
315
+ // the previous-pid list) is gone from the file, not just ignored.
316
+ return {
317
+ id: provider.id,
318
+ engine: provider.engine,
319
+ config: provider.config,
320
+ platform: provider.platform,
321
+ };
322
+ });
323
+ writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath, stored.lastLogs);
324
+ return providers;
325
+ }
273
326
  /**
274
327
  * buildSummary assembles the card's read model for one engine and config: the
275
328
  * static engine and model lists, the detected hardware, the form model the
@@ -359,40 +412,26 @@ export async function buildSummary(localInfer, request) {
359
412
  // to init) is re-adopted as plugin-launched, so its status dot reads as
360
413
  // running for its model and the OS-reserve resolver keeps the default. A
361
414
  // record whose engine is dead is cleared (the previous-pid list with it),
362
- // so a recycled pid cannot re-link to a config whose engine is down.
363
- const recorded = stored.providers.filter((provider) => provider.launch !== undefined);
364
- if (recorded.length > 0) {
365
- const outcomes = await localInfer.reLinkProviders(recorded.map((provider) => ({ id: provider.id, record: provider.launch })));
366
- if (recorded.some((provider) => outcomes[provider.id] === 'dead')) {
367
- const providers = stored.providers.map((provider) => {
368
- if (provider.launch === undefined || outcomes[provider.id] !== 'dead') {
369
- return provider;
370
- }
371
- return {
372
- id: provider.id,
373
- engine: provider.engine,
374
- config: provider.config,
375
- platform: provider.platform,
376
- };
377
- });
378
- writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath, stored.lastLogs);
379
- }
380
- }
415
+ // so a recycled pid cannot re-link to a config whose engine is down. The
416
+ // adopted engines also get a liveness watcher, so a later pkill settles the
417
+ // stored state without waiting for another summary read.
418
+ const linkedProviders = await relinkStoredProviders(localInfer, stored);
381
419
  // The provider list the card renders as collapsed entries: each saved
382
420
  // config's engine and model feed the detector, which resolves the running-
383
421
  // state its status dot paints. A provider the detector cannot tie to a
384
422
  // confirmed model reads as unsure, so the dot never overstates a live
385
- // engine.
386
- const providerRefs = stored.providers.map((provider) => {
423
+ // engine. The config rides into the detector so the signature prices the
424
+ // provider's own build mode and port, not the adapter's default-port guess.
425
+ const providerRefs = linkedProviders.map((provider) => {
387
426
  const model = typeof provider.config.model === 'string' ? provider.config.model : undefined;
388
427
  // exactOptionalPropertyTypes: include the model key only when it is a
389
428
  // string, so a config that names no model never writes `undefined`.
390
429
  return model !== undefined
391
- ? { id: provider.id, engine: provider.engine, model }
392
- : { id: provider.id, engine: provider.engine };
430
+ ? { id: provider.id, engine: provider.engine, model, config: provider.config }
431
+ : { id: provider.id, engine: provider.engine, config: provider.config };
393
432
  });
394
433
  const providerStatuses = providerRefs.length > 0 ? await localInfer.providerStatuses(providerRefs) : {};
395
- const providers = stored.providers.map((provider) => ({
434
+ const providers = linkedProviders.map((provider) => ({
396
435
  id: provider.id,
397
436
  engine: provider.engine,
398
437
  model: typeof provider.config.model === 'string' ? provider.config.model : '',
@@ -431,7 +470,23 @@ export async function buildSummary(localInfer, request) {
431
470
  // read as unregistered, which hides the Remove button (a no-op removal).
432
471
  }
433
472
  return {
434
- engines: ENGINES.map((entry) => ({ id: entry.id, label: entry.label, supports: entry.supports })),
473
+ engines: ENGINES.map((entry) => {
474
+ // The platform defaults ride the engine entry so the card's Engine
475
+ // platform section seeds the build-command and docker-tag placeholders
476
+ // from the same data the deploy step consumes.
477
+ const defaults = enginePlatformDefaults(entry.id);
478
+ return {
479
+ id: entry.id,
480
+ label: entry.label,
481
+ supports: entry.supports,
482
+ ...(defaults.defaultBuildCommand !== undefined
483
+ ? { defaultBuildCommand: defaults.defaultBuildCommand }
484
+ : {}),
485
+ ...(defaults.defaultDockerTag !== undefined
486
+ ? { defaultDockerTag: defaults.defaultDockerTag }
487
+ : {}),
488
+ };
489
+ }),
435
490
  models: localInfer.registeredVramModels().map((model) => ({ id: model.id, source: model.source })),
436
491
  hardware,
437
492
  engineId: engine.id,
@@ -502,7 +557,10 @@ function setPlatformConfig(localInfer, engineId, config) {
502
557
  if (config === undefined) {
503
558
  return;
504
559
  }
505
- if (config.engineDirectory === undefined && config.engineFork === undefined) {
560
+ if (config.engineDirectory === undefined &&
561
+ config.engineFork === undefined &&
562
+ config.buildCommands === undefined &&
563
+ config.dockerTag === undefined) {
506
564
  return;
507
565
  }
508
566
  const platform = { engineId, ...config };
@@ -670,16 +728,56 @@ function resolveAdapter(localInfer, engineId) {
670
728
  }
671
729
  return adapter;
672
730
  }
731
+ /**
732
+ * launchCommandNote renders the plan's child-process launch line for the
733
+ * card's Logs section: the core formatter's command under a [local-infer]
734
+ * launch prefix, newline-terminated. A plan that names no command (a bare
735
+ * docker plan with no argv) has no note, and the run logs stay as they are.
736
+ */
737
+ function launchCommandNote(plan) {
738
+ const command = formatLaunchCommand(plan);
739
+ if (command === undefined)
740
+ return undefined;
741
+ return `[local-infer] launch: ${command}\n`;
742
+ }
743
+ /**
744
+ * applyComposeArgs seeds a compose plan's compose arguments from the stored
745
+ * platform config (the card's docker-tag field for a compose engine, e.g.
746
+ * `--profile single`), so the up line the deploy runs and the down line the
747
+ * stop runs both select the profile the user named. A compose plan with no
748
+ * stored docker-tag keeps the engine's default profile; a non-compose plan is
749
+ * untouched.
750
+ */
751
+ function applyComposeArgs(plan, localInfer, engineId) {
752
+ if (plan.kind !== 'compose')
753
+ return;
754
+ const platform = localInfer.enginePlatformConfig(engineId);
755
+ if (platform?.dockerTag !== undefined) {
756
+ plan.composeArgs = platform.dockerTag;
757
+ }
758
+ }
759
+ /**
760
+ * persistStoredLogs overwrites the stored last-run logs (the card's Logs
761
+ * section shows them after a reload) and leaves every other stored field as
762
+ * the launch reads it.
763
+ */
764
+ function persistStoredLogs(logs) {
765
+ const stored = readConfig();
766
+ writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, stored.providers, stored.logPath, logs);
767
+ }
673
768
  /**
674
769
  * launchEngine renders the plan the registered adapter produces for the config
675
770
  * and the detected hardware, then runs the service's launch entry point
676
771
  * (deploy, then the health poll). The detail the launch returns names the
677
- * broken step when the engine does not come up. The launch step persists the
772
+ * broken step when the engine does not come up. The launch line (the engine
773
+ * binary and its arguments) leads the run logs the card's Logs section and
774
+ * the log file carry, so a broken setup diagnoses against the exact child
775
+ * process the launch started. The launch step persists the
678
776
  * launch record (the engine pid, start time, launch arguments, and the DSH
679
777
  * process pid that launched it) on the request's provider entry, so a DSH
680
778
  * restart can re-link a surviving engine to its config.
681
779
  */
682
- export async function launchEngine(localInfer, request) {
780
+ export async function launchEngine(localInfer, request, onProgress) {
683
781
  const stored = readConfig();
684
782
  const engineId = request.engine ?? stored.engine;
685
783
  const config = request.config ?? stored.config;
@@ -692,7 +790,56 @@ export async function launchEngine(localInfer, request) {
692
790
  if (request.platform !== undefined) {
693
791
  setPlatformConfig(localInfer, engineId, request.platform);
694
792
  }
695
- const result = await localInfer.launch(adapter.renderLaunch(config, hardware));
793
+ const plan = adapter.renderLaunch(config, hardware);
794
+ applyComposeArgs(plan, localInfer, engineId);
795
+ const commandNote = launchCommandNote(plan);
796
+ if (commandNote !== undefined) {
797
+ // Persist before the deploy starts: a slow or hung launch keeps the
798
+ // command readable in the Logs section across a card reload, where the
799
+ // engine output has not landed yet.
800
+ persistStoredLogs(commandNote);
801
+ }
802
+ // The prereqs phase: the system-dependency install the local build needs. It
803
+ // runs only while the Play/Run trust gate is on, the same rule the Get step
804
+ // applies; a failed install aborts the launch before the deploy starts. The
805
+ // card's button reads "Prereqs..." for this phase, so the phase is reported
806
+ // only while an install actually runs (the gate on), not when the deps are
807
+ // already satisfied and the phase is skipped.
808
+ if (stored.allowRunDeps === true) {
809
+ onProgress?.('prereqs');
810
+ const buildMode = plan.kind === 'docker' ? 'docker' : 'local';
811
+ const install = await localInfer.runInstall(engineId, buildMode, hardware);
812
+ if (!install.ok) {
813
+ const result = {
814
+ plan,
815
+ healthy: false,
816
+ detail: 'a system dependency install failed',
817
+ };
818
+ persistLaunchLogs(result, engineId, config);
819
+ persistLaunchRecord(result, engineId, config, request.providerId);
820
+ return result;
821
+ }
822
+ }
823
+ // The trust gate and the auto-rebuild flag ride into the deploy step, which
824
+ // reads them to decide whether a rebuild's missing-binary symlink into
825
+ // /usr/local/bin runs (on) or is written to the card's Logs section (off),
826
+ // and whether the git sync (fetch, rebase, rebuild) runs at all.
827
+ const result = await localInfer.launch(plan, {
828
+ ...(onProgress !== undefined ? { onProgress } : {}),
829
+ allowRunDeps: stored.allowRunDeps,
830
+ ...(request.autoRebuild !== undefined ? { autoRebuild: request.autoRebuild } : {}),
831
+ });
832
+ // Order the run logs: the build output (the rebuild decision, the build
833
+ // command, and its output) leads, the launch command follows it (after a
834
+ // successful build, or a direct launch with no build), and the engine's
835
+ // runtime output comes last. A deploy that failed before the engine printed
836
+ // anything still reports the command it tried to run.
837
+ const buildLogs = result.buildLogs ?? '';
838
+ const engineLogs = result.logs ?? '';
839
+ result.logs = buildLogs + (commandNote ?? '') + engineLogs;
840
+ if (result.buildLogs !== undefined) {
841
+ delete result.buildLogs;
842
+ }
696
843
  persistLaunchLogs(result, engineId, config);
697
844
  persistLaunchRecord(result, engineId, config, request.providerId);
698
845
  return result;
@@ -756,6 +903,47 @@ function clearLaunchRecord(engineId, config, providerId) {
756
903
  });
757
904
  writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath, stored.lastLogs);
758
905
  }
906
+ /**
907
+ * handleEngineDeath persists a provider entry's state when its engine process
908
+ * dies outside the plugin's stop path: a crash, a pkill of a plugin-launched
909
+ * child seen through the service's exit watch, or the poll-detected exit of a
910
+ * process adopted after a DSH restart. The entry whose launch record names the
911
+ * dead pid (or the dead engine-and-port, when the report carries no pid match)
912
+ * has its record cleared, so a restart never re-links a dead pid, and the exit
913
+ * line lands in the card's last-run logs beside the engine's own output. An
914
+ * event that matches no entry carrying a record (a stop the route already
915
+ * cleared, an engine the plugin never tracked) is a no-op, which keeps an
916
+ * expected stop quiet.
917
+ */
918
+ export function handleEngineDeath(event) {
919
+ const stored = readConfig();
920
+ let matched = false;
921
+ const providers = stored.providers.map((entry) => {
922
+ const record = entry.launch;
923
+ if (record === undefined)
924
+ return entry;
925
+ const byPid = event.pid !== undefined && record.pid === event.pid;
926
+ const byEnginePort = event.port !== undefined && record.engineId === entry.engine && record.port === event.port;
927
+ if (!byPid && !byEnginePort)
928
+ return entry;
929
+ matched = true;
930
+ // Rebuild the entry without the launch key, so the record (including the
931
+ // previous-pid list) is gone from the file, not just ignored.
932
+ return { id: entry.id, engine: entry.engine, config: entry.config, platform: entry.platform };
933
+ });
934
+ if (!matched)
935
+ return;
936
+ const signalNote = event.signal !== null && event.signal !== undefined
937
+ ? ` on signal ${event.signal}`
938
+ : ` with exit code ${event.code}`;
939
+ const note = `[local-infer] engine ${event.engineId}` +
940
+ `${event.port !== undefined ? ` (port ${event.port})` : ''}` +
941
+ ` pid ${event.pid ?? 'unknown'} exited${signalNote}` +
942
+ `${event.detail !== undefined ? `: ${event.detail}` : ''}\n`;
943
+ writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, providers, stored.logPath,
944
+ // Keep the last-run tail bounded, the same rule the launch logs cap uses.
945
+ (stored.lastLogs + note).slice(-65536));
946
+ }
759
947
  /**
760
948
  * persistLaunchLogs stores a launch's captured output as the last run and
761
949
  * writes it to the configured log file. The log file is only written when the
@@ -794,9 +982,143 @@ export async function stopEngine(localInfer, request) {
794
982
  const config = request.config ?? stored.config;
795
983
  const adapter = resolveAdapter(localInfer, engineId);
796
984
  const hardware = await localInfer.detectHardware();
797
- await localInfer.stop(adapter.renderLaunch(config, hardware));
985
+ const plan = adapter.renderLaunch(config, hardware);
986
+ applyComposeArgs(plan, localInfer, engineId);
987
+ await localInfer.stop(plan);
798
988
  clearLaunchRecord(engineId, config, request.providerId);
799
989
  }
990
+ /**
991
+ * resolveEngineDirectory picks the checkout the Get step syncs: the Engine
992
+ * Directory override when it names a path, else a per-engine folder inside
993
+ * the model directory (the default model directory, or the config's
994
+ * override), so a Get with no override still has a real place to live.
995
+ */
996
+ function resolveEngineDirectory(localInfer, config, engineId, platform) {
997
+ const override = platform?.engineDirectory;
998
+ if (override !== undefined && override.trim() !== '') {
999
+ return override;
1000
+ }
1001
+ const rawModelDir = config.modelDirectory;
1002
+ const modelDir = typeof rawModelDir === 'string' && rawModelDir.trim() !== ''
1003
+ ? rawModelDir
1004
+ : localInfer.defaultModelDirectory();
1005
+ return join(modelDir, engineId);
1006
+ }
1007
+ /**
1008
+ * resolveModelDirectory picks the model directory a Get run downloads into:
1009
+ * the config's modelDirectory override when it names a path, else the
1010
+ * plugin's default model directory. The same rule the summary and deps routes
1011
+ * apply, so a Get and the card's Model Directory field agree on the target.
1012
+ */
1013
+ function resolveModelDirectory(localInfer, config) {
1014
+ const rawModelDir = config.modelDirectory;
1015
+ return typeof rawModelDir === 'string' && rawModelDir.trim() !== ''
1016
+ ? rawModelDir
1017
+ : localInfer.defaultModelDirectory();
1018
+ }
1019
+ /**
1020
+ * getEngine is the Get step: it syncs the engine checkout (clone the git
1021
+ * repo, or the fork when the platform entry provides one; fetch and rebase
1022
+ * a clean checkout; abort with a Logs alert when the checkout has local
1023
+ * changes), runs the system dependency plan, and runs the engine's build
1024
+ * steps. Every command and its output streams through log as it lands, so
1025
+ * the card appends it to the Logs section live. The dependency install only
1026
+ * runs while the Play/Run trust gate is on; with the gate off the deps are
1027
+ * re-checked and a missing one aborts the step with the copy/paste note.
1028
+ */
1029
+ export async function getEngine(localInfer, request, log, host = nodeLifecycleHost) {
1030
+ const stored = readConfig();
1031
+ const engineId = request.engine ?? stored.engine;
1032
+ const config = request.config ?? stored.config;
1033
+ // ENGINES is a known non-empty constant, so the fallback is always defined.
1034
+ const engine = ENGINES.find((entry) => entry.id === engineId) ?? ENGINES[0];
1035
+ // Register the request's platform override the same way launchEngine does,
1036
+ // so the next launch reads the Engine Directory / Engine Fork the Get used.
1037
+ if (request.platform !== undefined) {
1038
+ setPlatformConfig(localInfer, engineId, request.platform);
1039
+ }
1040
+ const platform = request.platform ?? stored.platform[engineId];
1041
+ const directory = resolveEngineDirectory(localInfer, config, engineId, platform);
1042
+ const fork = platform?.engineFork;
1043
+ log(`get ${engineId} in ${directory}\n`);
1044
+ const sync = await syncEngineRepo(host, { engineId, directory, ...(fork !== undefined && fork.trim() !== '' ? { fork } : {}) }, log);
1045
+ if (!sync.ok) {
1046
+ // A dirty checkout already alerted in the sync step; any other failure
1047
+ // names its step here.
1048
+ if (sync.aborted !== true && sync.detail !== undefined) {
1049
+ log(`!! ${sync.detail}\n`);
1050
+ }
1051
+ return { ok: false, ...(sync.detail !== undefined ? { detail: sync.detail } : {}) };
1052
+ }
1053
+ const hardware = await localInfer.detectHardware();
1054
+ const buildMode = resolveBuildMode(engine, config);
1055
+ log(`system dependencies (${buildMode})\n`);
1056
+ if (stored.allowRunDeps === true) {
1057
+ const result = await localInfer.runInstall(engineId, buildMode, hardware);
1058
+ for (const entry of result.results) {
1059
+ log(` ${entry.id}: ${entry.status}${entry.detail !== undefined ? ` (${entry.detail})` : ''}\n`);
1060
+ }
1061
+ if (!result.ok) {
1062
+ log('!! a system dependency install failed; copy the install string from the panel\n');
1063
+ return { ok: false, detail: 'a system dependency install failed' };
1064
+ }
1065
+ }
1066
+ else {
1067
+ const deps = await localInfer.deps(engineId, buildMode, hardware);
1068
+ const checks = await checkDeps(deps, hardware);
1069
+ let missing = false;
1070
+ for (const check of checks) {
1071
+ log(` ${check.dep.id}: ${check.status}\n`);
1072
+ if (check.status !== 'satisfied')
1073
+ missing = true;
1074
+ }
1075
+ if (missing) {
1076
+ log('!! a system dependency is missing and one-click install is off; copy the install string from the panel\n');
1077
+ return { ok: false, detail: 'a system dependency is missing' };
1078
+ }
1079
+ }
1080
+ const build = await buildEngineRepo(host, engineId, directory, log);
1081
+ if (!build.ok) {
1082
+ if (build.detail !== undefined) {
1083
+ log(`!! ${build.detail}\n`);
1084
+ }
1085
+ return { ok: false, ...(build.detail !== undefined ? { detail: build.detail } : {}) };
1086
+ }
1087
+ // The model artifacts a Get leaves ready to launch: the Infernix DFlash2
1088
+ // drafter (when a preset is selected) and the PLE n-gram volume (when the
1089
+ // model key names one), pulled through the hf CLI into the model directory.
1090
+ // A download failure aborts the Get, so a launch never reads a missing
1091
+ // drafter or table.
1092
+ if (engineId === 'infernix') {
1093
+ const modelDir = resolveModelDirectory(localInfer, config);
1094
+ for (const command of infernixGetDownloads(config, modelDir)) {
1095
+ log(`$ ${command}\n`);
1096
+ const outcome = host.runStream !== undefined
1097
+ ? await host.runStream(command, (chunk) => log(chunk))
1098
+ : await host.run(command);
1099
+ if (!outcome.ok) {
1100
+ log(`!! ${command} failed: ${outcome.detail ?? 'unknown'}\n`);
1101
+ return { ok: false, detail: 'a model artifact download failed' };
1102
+ }
1103
+ }
1104
+ }
1105
+ log('get complete\n');
1106
+ return { ok: true };
1107
+ }
1108
+ /**
1109
+ * persistGetLogs appends one Get run's streamed output to the stored last-
1110
+ * run logs (the card's Logs section shows it after a reload) and writes no
1111
+ * log file: the launch step owns the per-run files. The tail rule matches
1112
+ * the launch logs' cap, so a long build cannot grow the config without
1113
+ * bound.
1114
+ */
1115
+ function persistGetLogs(output) {
1116
+ if (output === '')
1117
+ return;
1118
+ const stored = readConfig();
1119
+ const joined = `${stored.lastLogs}${stored.lastLogs === '' ? '' : '\n'}${output}`;
1120
+ writeConfig(stored.engine, stored.config, stored.allowRunDeps, stored.platform, stored.showHelp, stored.providers, stored.logPath, joined.slice(-65536));
1121
+ }
800
1122
  /**
801
1123
  * resolveContextWindow picks the context window the registrar publishes: the
802
1124
  * config's maxContext when it names one, else the native ceiling of the
@@ -894,13 +1216,16 @@ export async function unregisterFor(localInfer, request) {
894
1216
  return { removed: true };
895
1217
  }
896
1218
  /**
897
- * registerLocalInferRoute registers the summary, save, deps, install, launch,
898
- * stop, register, and unregister routes on the web server and holds them by
899
- * an effect, so a bundle switch off removes them before the next switch on
900
- * re-registers. The scoped context carries the webServer; the localInfer
901
- * service is passed in because the scope only injects what it names.
1219
+ * registerLocalInferRoute registers the summary, save, deps, install, get,
1220
+ * launch, stop, register, and unregister routes on the web server and holds
1221
+ * them by an effect, so a bundle switch off removes them before the next
1222
+ * switch on re-registers. The scoped context carries the webServer; the
1223
+ * localInfer service is passed in because the scope only injects what it
1224
+ * names. The optional getHost is the lifecycle host the Get route's git and
1225
+ * build steps run through; it defaults to the node host, and a test injects
1226
+ * a stub so the route never clones a real repo in CI.
902
1227
  */
903
- export function registerLocalInferRoute(scope, localInfer) {
1228
+ export function registerLocalInferRoute(scope, localInfer, getHost) {
904
1229
  const summary = async (url) => {
905
1230
  const engineParam = url.searchParams.get('engine');
906
1231
  const configParam = url.searchParams.get('config');
@@ -990,23 +1315,33 @@ export function registerLocalInferRoute(scope, localInfer) {
990
1315
  // reads, so a provider never carries a field it cannot price. A save
991
1316
  // that updates an entry in place carries the entry's launch record
992
1317
  // over, so editing a running provider's config never drops the record
993
- // the restart re-link prices.
1318
+ // the restart re-link prices. A settings-only save skips the upsert:
1319
+ // it persists the global fields the toggle edits and keeps the stored
1320
+ // list intact, so the help and allow-run toggles never mint a
1321
+ // duplicate entry.
994
1322
  const stored = readConfig();
995
- const provider = {
996
- id: providerId ?? newProviderId(),
997
- engine,
998
- config,
999
- platform: platform[engine] ?? {},
1000
- };
1001
- if (providerId !== undefined) {
1002
- const existing = stored.providers.find((entry) => entry.id === providerId);
1003
- if (existing?.launch !== undefined) {
1004
- provider.launch = existing.launch;
1323
+ let providers;
1324
+ if (body.settingsOnly === true) {
1325
+ providers = stored.providers;
1326
+ }
1327
+ else {
1328
+ const provider = {
1329
+ id: providerId ?? newProviderId(),
1330
+ engine,
1331
+ config,
1332
+ platform: platform[engine] ?? {},
1333
+ };
1334
+ if (providerId !== undefined) {
1335
+ const existing = stored.providers.find((entry) => entry.id === providerId);
1336
+ if (existing?.launch !== undefined) {
1337
+ provider.launch = existing.launch;
1338
+ }
1005
1339
  }
1340
+ providers =
1341
+ providerId !== undefined
1342
+ ? stored.providers.map((entry) => (entry.id === providerId ? provider : entry))
1343
+ : [provider, ...stored.providers];
1006
1344
  }
1007
- const providers = providerId !== undefined
1008
- ? stored.providers.map((entry) => (entry.id === providerId ? provider : entry))
1009
- : [provider, ...stored.providers];
1010
1345
  writeConfig(engine, config, allowRunDeps, platform, showHelp, providers, logPath);
1011
1346
  // Re-sync the service registry with the saved platform map, so the next
1012
1347
  // launch reads the platform config the user just saved.
@@ -1265,6 +1600,68 @@ export function registerLocalInferRoute(scope, localInfer) {
1265
1600
  }
1266
1601
  },
1267
1602
  };
1603
+ // The Get route: a POST whose answer streams as plain text, so the card
1604
+ // appends the git sync, the dependency run, and the build output to its
1605
+ // Logs section as it lands. The full run persists as the last-run logs.
1606
+ const get = {
1607
+ name: 'local-infer-get',
1608
+ kind: 'exact',
1609
+ path: '/local-infer/get',
1610
+ handler: async (req, res) => {
1611
+ const send = (status, body) => {
1612
+ res.writeHead(status, { 'content-type': 'application/json' });
1613
+ res.end(JSON.stringify(body));
1614
+ };
1615
+ if (req.method !== 'POST') {
1616
+ res.writeHead(405).end();
1617
+ return;
1618
+ }
1619
+ try {
1620
+ const chunks = [];
1621
+ let total = 0;
1622
+ for await (const chunk of req) {
1623
+ total += chunk.length;
1624
+ if (total > 64 * 1024) {
1625
+ send(413, { error: 'get payload too large' });
1626
+ req.destroy();
1627
+ return;
1628
+ }
1629
+ chunks.push(chunk);
1630
+ }
1631
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1632
+ const request = {};
1633
+ if (typeof body.engine === 'string') {
1634
+ request.engine = body.engine;
1635
+ }
1636
+ if (body.config && typeof body.config === 'object') {
1637
+ request.config = body.config;
1638
+ }
1639
+ const platform = parseStoredPlatformConfig(body.platform);
1640
+ if (platform !== undefined) {
1641
+ request.platform = platform;
1642
+ }
1643
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
1644
+ let collected = '';
1645
+ const log = (text) => {
1646
+ collected += text;
1647
+ if (typeof res.write === 'function') {
1648
+ res.write(text);
1649
+ }
1650
+ };
1651
+ try {
1652
+ await getEngine(localInfer, request, log, getHost);
1653
+ }
1654
+ catch (error) {
1655
+ log(`!! get failed: ${String(error?.message ?? error)}\n`);
1656
+ }
1657
+ persistGetLogs(collected);
1658
+ res.end();
1659
+ }
1660
+ catch (error) {
1661
+ send(400, { error: String(error?.message ?? error) });
1662
+ }
1663
+ },
1664
+ };
1268
1665
  // The launch and stop routes share a POST body parser: the engine, config,
1269
1666
  // and platform override the plan renders from, falling back to the stored
1270
1667
  // config per key. The stop route ignores the platform key. `providerId`
@@ -1285,6 +1682,9 @@ export function registerLocalInferRoute(scope, localInfer) {
1285
1682
  if (typeof body.providerId === 'string' && body.providerId.trim() !== '') {
1286
1683
  request.providerId = body.providerId;
1287
1684
  }
1685
+ if (typeof body.autoRebuild === 'boolean') {
1686
+ request.autoRebuild = body.autoRebuild;
1687
+ }
1288
1688
  return request;
1289
1689
  };
1290
1690
  const launch = {
@@ -1313,7 +1713,34 @@ export function registerLocalInferRoute(scope, localInfer) {
1313
1713
  chunks.push(chunk);
1314
1714
  }
1315
1715
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1316
- send(200, await launchEngine(localInfer, launchRequestFromBody(body)));
1716
+ // The launch streams its progress as NDJSON: a step event as each phase
1717
+ // (prereqs, download, build, launch) begins, then a final result event.
1718
+ // The card's Launch control reads the step that is running and times
1719
+ // out only the launch (health-poll) step; the rebuild steps run as long
1720
+ // as they need to.
1721
+ res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8' });
1722
+ const writeLine = (line) => {
1723
+ if (typeof res.write === 'function') {
1724
+ res.write(JSON.stringify(line) + '\n');
1725
+ }
1726
+ };
1727
+ const onProgress = (step) => {
1728
+ writeLine({ step });
1729
+ };
1730
+ try {
1731
+ const result = await launchEngine(localInfer, launchRequestFromBody(body), onProgress);
1732
+ writeLine({ result });
1733
+ }
1734
+ catch (error) {
1735
+ // A launch that breaks before a plan renders (an unknown engine, a
1736
+ // hardware probe failure) still settles the card's control to the red
1737
+ // Logs state through a result event, not a bare 500.
1738
+ const detail = error instanceof RouteError
1739
+ ? error.message
1740
+ : String(error?.message ?? error);
1741
+ writeLine({ result: { healthy: false, detail } });
1742
+ }
1743
+ res.end();
1317
1744
  }
1318
1745
  catch (error) {
1319
1746
  if (error instanceof RouteError) {
@@ -1457,6 +1884,16 @@ export function registerLocalInferRoute(scope, localInfer) {
1457
1884
  // route registration, so a launch before the first save reads the
1458
1885
  // platform config the user last saved.
1459
1886
  syncPlatformConfigs(localInfer, readConfig());
1887
+ // The engine death observer: a crash, a pkill, or the poll-detected exit
1888
+ // of an adopted process clears the provider entry's launch record and
1889
+ // notes the exit in the last-run logs the moment it happens, so the card
1890
+ // and a later restart read the provider stopped rather than stale.
1891
+ localInfer.setEngineDeathObserver(handleEngineDeath);
1892
+ // The mount re-link: re-adopt a surviving engine from a stored launch
1893
+ // record and start its liveness watcher without waiting for the card to
1894
+ // open the summary route, so a DSH that restarts re-attaches its engines
1895
+ // and a pkill after the restart still settles the stored state.
1896
+ void relinkStoredProviders(localInfer, readConfig()).catch(() => undefined);
1460
1897
  const disposeSummary = scope.webServer.register(route);
1461
1898
  const disposeSave = scope.webServer.register(save);
1462
1899
  const disposeDeleteProvider = scope.webServer.register(deleteProvider);
@@ -1464,11 +1901,13 @@ export function registerLocalInferRoute(scope, localInfer) {
1464
1901
  const disposeInstall = scope.webServer.register(install);
1465
1902
  const disposeDownload = scope.webServer.register(download);
1466
1903
  const disposeChatTemplate = scope.webServer.register(chatTemplate);
1904
+ const disposeGet = scope.webServer.register(get);
1467
1905
  const disposeLaunch = scope.webServer.register(launch);
1468
1906
  const disposeStop = scope.webServer.register(stop);
1469
1907
  const disposeRegister = scope.webServer.register(register);
1470
1908
  const disposeUnregister = scope.webServer.register(unregister);
1471
1909
  return () => {
1910
+ localInfer.setEngineDeathObserver(undefined);
1472
1911
  disposeSummary();
1473
1912
  disposeSave();
1474
1913
  disposeDeleteProvider();
@@ -1476,6 +1915,7 @@ export function registerLocalInferRoute(scope, localInfer) {
1476
1915
  disposeInstall();
1477
1916
  disposeDownload();
1478
1917
  disposeChatTemplate();
1918
+ disposeGet();
1479
1919
  disposeLaunch();
1480
1920
  disposeStop();
1481
1921
  disposeRegister();