@sparkelf/dsh-dataops-managed 0.3.11 → 0.3.12

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/lib/index.js CHANGED
@@ -14,17 +14,17 @@ function modelIdOf(entry) {
14
14
  return typeof id === "string" && id.trim() !== "" ? id : null;
15
15
  }
16
16
  /** Narrow an unknown value to a plain object. */
17
- function asRecord$2(value) {
17
+ function asRecord$3(value) {
18
18
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
19
19
  }
20
20
  /** Read provider profiles out of a settings value. */
21
21
  function providersOf(value) {
22
- const root = asRecord$2(value);
23
- const providers = root === null ? null : asRecord$2(root[PROVIDERS_FIELD]);
22
+ const root = asRecord$3(value);
23
+ const providers = root === null ? null : asRecord$3(root[PROVIDERS_FIELD]);
24
24
  if (providers === null) return {};
25
25
  const result = {};
26
26
  for (const [providerId, profile] of Object.entries(providers)) {
27
- const narrowed = asRecord$2(profile);
27
+ const narrowed = asRecord$3(profile);
28
28
  if (narrowed !== null) result[providerId] = narrowed;
29
29
  }
30
30
  return result;
@@ -58,7 +58,7 @@ function modelIdsByProvider(providers) {
58
58
  for (const [providerId, profile] of Object.entries(providers)) {
59
59
  const models = profile[MODELS_FIELD];
60
60
  if (!Array.isArray(models)) continue;
61
- result[providerId] = models.map((entry) => asRecord$2(entry)).filter((entry) => entry !== null).map((entry) => modelIdOf(entry)).filter((id) => id !== null);
61
+ result[providerId] = models.map((entry) => asRecord$3(entry)).filter((entry) => entry !== null).map((entry) => modelIdOf(entry)).filter((id) => id !== null);
62
62
  }
63
63
  return result;
64
64
  }
@@ -79,7 +79,7 @@ function selectSharedModels(providers, sharedModelIds) {
79
79
  const models = profile[MODELS_FIELD];
80
80
  if (!Array.isArray(models)) continue;
81
81
  const kept = models.filter((entry) => {
82
- const narrowed = asRecord$2(entry);
82
+ const narrowed = asRecord$3(entry);
83
83
  const id = narrowed === null ? null : modelIdOf(narrowed);
84
84
  return id !== null && offered.has(id);
85
85
  });
@@ -113,9 +113,9 @@ function mergeProviders(local, adopted) {
113
113
  continue;
114
114
  }
115
115
  const localModels = Array.isArray(localProfile[MODELS_FIELD]) ? localProfile[MODELS_FIELD] : [];
116
- const localIds = new Set(localModels.map((entry) => asRecord$2(entry)).filter((entry) => entry !== null).map((entry) => modelIdOf(entry)).filter((id) => id !== null));
116
+ const localIds = new Set(localModels.map((entry) => asRecord$3(entry)).filter((entry) => entry !== null).map((entry) => modelIdOf(entry)).filter((id) => id !== null));
117
117
  const missing = adoptedModels.filter((entry) => {
118
- const narrowed = asRecord$2(entry);
118
+ const narrowed = asRecord$3(entry);
119
119
  const id = narrowed === null ? null : modelIdOf(narrowed);
120
120
  return id !== null && !localIds.has(id);
121
121
  });
@@ -147,7 +147,7 @@ async function writeModels(ctx, providers, revision) {
147
147
  //#endregion
148
148
  //#region src/model-sync.ts
149
149
  /** Narrow an unknown value to a plain object. */
150
- function asRecord$1(value) {
150
+ function asRecord$2(value) {
151
151
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
152
152
  }
153
153
  /**
@@ -190,10 +190,10 @@ var ModelSync = class {
190
190
  });
191
191
  if (response.status === 401) return void 0;
192
192
  if (!response.ok) throw new Error(`Shared model read failed with HTTP ${String(response.status)}`);
193
- const body = asRecord$1(await response.json());
193
+ const body = asRecord$2(await response.json());
194
194
  if (body === null) return void 0;
195
- const payload = asRecord$1(body.payload);
196
- const providers = payload === null ? null : asRecord$1(payload.providers);
195
+ const payload = asRecord$2(body.payload);
196
+ const providers = payload === null ? null : asRecord$2(payload.providers);
197
197
  return {
198
198
  sharingEnabled: body.sharingEnabled === true,
199
199
  payload: {
@@ -238,7 +238,8 @@ var ModelSync = class {
238
238
  body: JSON.stringify({
239
239
  sharingEnabled,
240
240
  providers: current.providers,
241
- sharedModelIds: this.offeredModelIds(current.providers, state.privateModelIds)
241
+ sharedModelIds: this.offeredModelIds(current.providers, state.privateModelIds),
242
+ sections: await this.config.collectSections?.() ?? []
242
243
  })
243
244
  });
244
245
  if (!response.ok) throw new Error(`Shared model publish failed with HTTP ${String(response.status)}`);
@@ -256,7 +257,7 @@ var ModelSync = class {
256
257
  const response = await fetch(new URL("/api/ai/shared-models/publication", this.apiBase()), { headers: { authorization: `Bearer ${token}` } });
257
258
  if (response.status === 401) return null;
258
259
  if (!response.ok) throw new Error(`Shared model status read failed with HTTP ${String(response.status)}`);
259
- const body = asRecord$1(await response.json());
260
+ const body = asRecord$2(await response.json());
260
261
  return {
261
262
  sharingEnabled: body?.sharingEnabled === true,
262
263
  canPublish: body?.canPublish === true
@@ -335,6 +336,23 @@ var ModelSync = class {
335
336
  });
336
337
  }
337
338
  /**
339
+ * Read the settings sections the current publication offers.
340
+ *
341
+ * Separate from `adopt`, which owns the models: a workspace adopts models and settings from one
342
+ * publication but keeps separate bookkeeping for each, because a user who changed the permission
343
+ * default has not thereby chosen their own models.
344
+ * @returns The offered sections, or an empty list when nobody publishes or none are offered.
345
+ */
346
+ async readPublishedSections() {
347
+ try {
348
+ const published = await this.fetchPublished();
349
+ if (published === void 0 || !published.sharingEnabled) return [];
350
+ return published.payload.sections ?? [];
351
+ } catch (error) {
352
+ return [];
353
+ }
354
+ }
355
+ /**
338
356
  * Follow the publisher again, adopting its models on the next pass.
339
357
  */
340
358
  async resumeFollowing() {
@@ -369,6 +387,151 @@ var ModelSync = class {
369
387
  }
370
388
  };
371
389
  //#endregion
390
+ //#region src/settings-sync.ts
391
+ /** Default state before this workspace stored anything. */
392
+ const SECTION_SYNC_DEFAULT = { sections: {} };
393
+ /** Narrow an unknown value to a plain object. */
394
+ function asRecord$1(value) {
395
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
396
+ }
397
+ /**
398
+ * A stable rendering of one section's values.
399
+ *
400
+ * Key order is normalized so two reads of the same values match, and a publication that only
401
+ * reordered its keys does not read as a change the user made.
402
+ * @param value - Section values.
403
+ * @returns A fingerprint comparable across reads.
404
+ */
405
+ function fingerprintSection(value) {
406
+ const keys = Object.keys(value).sort();
407
+ const normalized = {};
408
+ for (const key of keys) normalized[key] = value[key];
409
+ return JSON.stringify(normalized);
410
+ }
411
+ /**
412
+ * Own the distributed settings one workspace keeps.
413
+ *
414
+ * The state lives in this workspace rather than in DataOps because whether a user has written
415
+ * their own values is a fact about this workspace, and keeping it here is what makes the promise
416
+ * hold across restarts: a user who chose their own settings is not adopted again.
417
+ */
418
+ var SettingsSync = class {
419
+ ctx;
420
+ store;
421
+ constructor(ctx, store) {
422
+ this.ctx = ctx;
423
+ this.store = store;
424
+ }
425
+ /**
426
+ * Read one namespace as this workspace currently holds it.
427
+ * @param ns - Settings namespace to read.
428
+ * @returns The values and revision, or undefined when no plugin owns that namespace.
429
+ */
430
+ readSection(ns) {
431
+ const descriptor = this.ctx.settings.describe().find((entry) => entry.ns === ns);
432
+ if (descriptor === void 0) return void 0;
433
+ const value = asRecord$1(descriptor.value);
434
+ if (value === null) return void 0;
435
+ return {
436
+ value,
437
+ revision: descriptor.revision
438
+ };
439
+ }
440
+ /** Read this workspace's per-namespace bookkeeping. */
441
+ recordOf(state, ns) {
442
+ return state.sections[ns] ?? {
443
+ detached: false,
444
+ adoptedFingerprint: null
445
+ };
446
+ }
447
+ /**
448
+ * Take the published values for every namespace this workspace has not already diverged from.
449
+ *
450
+ * A namespace the user has written is skipped and marked detached, so a local edit is never
451
+ * replaced. A namespace whose current values match what adoption last wrote is still following,
452
+ * so the publisher's newer values replace them. Everything else is a default a user may change.
453
+ * @param sections - Sections the publisher offers.
454
+ * @returns Namespaces that were written, for logging.
455
+ */
456
+ async adopt(sections) {
457
+ const state = this.store.read();
458
+ const next = { sections: { ...state.sections } };
459
+ const written = [];
460
+ let stateChanged = false;
461
+ for (const section of sections) {
462
+ const record = this.recordOf(state, section.ns);
463
+ if (record.detached) continue;
464
+ const current = this.readSection(section.ns);
465
+ if (current === void 0) continue;
466
+ const currentFingerprint = fingerprintSection(current.value);
467
+ const publishedFingerprint = fingerprintSection(section.value);
468
+ if (record.adoptedFingerprint !== null && currentFingerprint !== record.adoptedFingerprint) {
469
+ next.sections[section.ns] = {
470
+ detached: true,
471
+ adoptedFingerprint: record.adoptedFingerprint
472
+ };
473
+ stateChanged = true;
474
+ continue;
475
+ }
476
+ if (currentFingerprint === publishedFingerprint) {
477
+ if (record.adoptedFingerprint !== publishedFingerprint) {
478
+ next.sections[section.ns] = {
479
+ detached: false,
480
+ adoptedFingerprint: publishedFingerprint
481
+ };
482
+ stateChanged = true;
483
+ }
484
+ continue;
485
+ }
486
+ await this.ctx.settings.update(section.ns, section.value);
487
+ next.sections[section.ns] = {
488
+ detached: false,
489
+ adoptedFingerprint: publishedFingerprint
490
+ };
491
+ stateChanged = true;
492
+ written.push(section.ns);
493
+ }
494
+ if (stateChanged) await this.store.write(next);
495
+ return written;
496
+ }
497
+ /**
498
+ * Describe the distributed settings for the settings UI.
499
+ *
500
+ * Reports every namespace the deployment offers, whether this workspace still follows the
501
+ * publisher for it, and whether a plugin here owns it. A namespace no plugin owns cannot be
502
+ * applied, so a panel marks that row rather than offering a control that would do nothing.
503
+ * @param namespaces - Namespaces the deployment distributes.
504
+ * @returns One entry per namespace, in the order given.
505
+ */
506
+ status(namespaces) {
507
+ const state = this.store.read();
508
+ return namespaces.map((ns) => {
509
+ const record = this.recordOf(state, ns);
510
+ return {
511
+ ns,
512
+ followState: record.detached ? "detached" : "following",
513
+ applied: record.adoptedFingerprint !== null,
514
+ mounted: this.readSection(ns) !== void 0
515
+ };
516
+ });
517
+ }
518
+ /**
519
+ * Stop following the publisher for one namespace and keep what this workspace holds now.
520
+ * @param ns - Settings namespace to detach.
521
+ */
522
+ async detach(ns) {
523
+ const state = this.store.read();
524
+ const record = this.recordOf(state, ns);
525
+ await this.store.write({ sections: {
526
+ ...state.sections,
527
+ [ns]: {
528
+ ...record,
529
+ detached: true
530
+ }
531
+ } });
532
+ }
533
+ };
534
+ //#endregion
372
535
  //#region src/skill-plaza.ts
373
536
  /** Narrow an unknown value to a plain object. */
374
537
  function asRecord(value) {
@@ -549,6 +712,12 @@ function currentSkillPlaza() {
549
712
  function currentModelSync() {
550
713
  return modelSyncRef;
551
714
  }
715
+ /** The live settings-sync instance, once this plugin activated. */
716
+ let settingsSyncRef;
717
+ /** @returns The live instance, or undefined before this plugin activated. */
718
+ function currentSettingsSync() {
719
+ return settingsSyncRef;
720
+ }
552
721
  /** Schemastery parser for managed DataOps configuration. */
553
722
  const Config = z.object({
554
723
  baseUrl: z.string().default("http://host.docker.internal:3101"),
@@ -563,7 +732,14 @@ const Config = z.object({
563
732
  detached: false,
564
733
  adoptedModelIds: [],
565
734
  privateModelIds: []
566
- }).volatile()
735
+ }).volatile(),
736
+ /** Volatile for the same reason as `modelSync`: it is this plugin's own record. */
737
+ settingsSync: z.any().default(SECTION_SYNC_DEFAULT).volatile(),
738
+ /**
739
+ * Namespaces offered in a publication. Validated as strings so a malformed profile entry fails
740
+ * at load rather than producing a publication no reader can apply.
741
+ */
742
+ distributedSettingsNamespaces: z.array(z.string()).default([]).description("Settings namespaces offered to other workspaces when this one publishes.")
567
743
  });
568
744
  function bearerToken(request) {
569
745
  const authorization = request.headers.authorization?.trim() ?? "";
@@ -606,10 +782,11 @@ async function apply(ctx, config) {
606
782
  if (fiber !== void 0 && fiber.uid !== null) await fiber.dispose();
607
783
  }, "dataops-managed: MCP lifecycle");
608
784
  if (await ctx.credentials.resolve(accessRef) !== void 0) await ensureMcp();
609
- const modelSync = new ModelSync(ctx, {
610
- baseUrl,
611
- credentialRef: config.credentialRef
612
- }, {
785
+ /**
786
+ * The sync state lives in this plugin's own Config, so the settings service persists it in
787
+ * the workspace document and it survives a restart.
788
+ */
789
+ const syncStore = {
613
790
  read: () => ({
614
791
  detached: config.modelSync?.detached === true,
615
792
  adoptedModelIds: Array.isArray(config.modelSync?.adoptedModelIds) ? config.modelSync.adoptedModelIds : [],
@@ -618,7 +795,49 @@ async function apply(ctx, config) {
618
795
  write: async (next) => {
619
796
  await ctx.settings.update(MANAGED_PLUGIN_ID, { modelSync: next });
620
797
  }
798
+ };
799
+ const settingsSync = new SettingsSync(ctx, {
800
+ read: () => ({ sections: typeof config.settingsSync?.sections === "object" && config.settingsSync.sections !== null ? config.settingsSync.sections : {} }),
801
+ write: async (next) => {
802
+ await ctx.settings.update(MANAGED_PLUGIN_ID, { settingsSync: next });
803
+ }
621
804
  });
805
+ settingsSyncRef = settingsSync;
806
+ /**
807
+ * The namespaces this deployment distributes.
808
+ *
809
+ * Listed here rather than inside the sync because what a deployment offers is its own choice:
810
+ * the sync knows how to carry a section, and this decides which ones travel.
811
+ */
812
+ const distributedNamespaces = () => {
813
+ const configured = config.distributedSettingsNamespaces;
814
+ return Array.isArray(configured) ? configured.filter((ns) => typeof ns === "string" && ns !== "") : [];
815
+ };
816
+ /**
817
+ * Read the sections this workspace would publish.
818
+ *
819
+ * A namespace no plugin owns is skipped: publishing a section this deployment cannot apply
820
+ * would offer readers values nothing on their side reads.
821
+ */
822
+ const collectSections = async () => {
823
+ const collected = [];
824
+ for (const ns of distributedNamespaces()) {
825
+ const descriptor = ctx.settings.describe().find((entry) => entry.ns === ns);
826
+ if (descriptor === void 0) continue;
827
+ const value = descriptor.value;
828
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
829
+ collected.push({
830
+ ns,
831
+ value
832
+ });
833
+ }
834
+ return collected;
835
+ };
836
+ const modelSync = new ModelSync(ctx, {
837
+ baseUrl,
838
+ credentialRef: config.credentialRef,
839
+ collectSections
840
+ }, syncStore);
622
841
  modelSyncRef = modelSync;
623
842
  skillPlazaRef = new SkillPlaza(ctx, {
624
843
  baseUrl,
@@ -637,6 +856,13 @@ async function apply(ctx, config) {
637
856
  ctx.logger.warn("dataops-managed: model sync could not adopt the published models");
638
857
  ctx.logger.warn(error);
639
858
  }
859
+ try {
860
+ const published = await modelSync.readPublishedSections();
861
+ if (published.length > 0) await settingsSync.adopt(published);
862
+ } catch (error) {
863
+ ctx.logger.warn("dataops-managed: distributed settings could not be adopted");
864
+ ctx.logger.warn(error);
865
+ }
640
866
  };
641
867
  if (await ctx.credentials.resolve(accessRef) !== void 0) await adoptNow();
642
868
  ctx.effect(() => ctx.webServer.register({
@@ -682,7 +908,10 @@ async function apply(ctx, config) {
682
908
  }
683
909
  try {
684
910
  if (request.method === "GET") {
685
- writeJson(response, 200, await sync.status());
911
+ writeJson(response, 200, {
912
+ ...await sync.status(),
913
+ sections: settingsSyncRef === void 0 ? [] : settingsSyncRef.status(distributedNamespaces())
914
+ });
686
915
  return;
687
916
  }
688
917
  if (request.method !== "POST") {
@@ -701,11 +930,32 @@ async function apply(ctx, config) {
701
930
  writeJson(response, 200, await sync.status());
702
931
  return;
703
932
  }
933
+ if (action === "publish-defaults") {
934
+ await sync.publish(true);
935
+ writeJson(response, 200, {
936
+ ...await sync.status(),
937
+ sections: settingsSyncRef === void 0 ? [] : settingsSyncRef.status(distributedNamespaces())
938
+ });
939
+ return;
940
+ }
704
941
  if (action === "detach") {
705
942
  await sync.detach();
706
943
  writeJson(response, 200, await sync.status());
707
944
  return;
708
945
  }
946
+ if (action === "detach-section") {
947
+ const ns = typeof body?.ns === "string" ? body.ns.trim() : "";
948
+ if (ns === "") {
949
+ writeJson(response, 400, { error: "detach-section needs a namespace" });
950
+ return;
951
+ }
952
+ await settingsSyncRef?.detach(ns);
953
+ writeJson(response, 200, {
954
+ ...await sync.status(),
955
+ sections: settingsSyncRef === void 0 ? [] : settingsSyncRef.status(distributedNamespaces())
956
+ });
957
+ return;
958
+ }
709
959
  if (action === "resume") {
710
960
  await sync.resumeFollowing();
711
961
  await sync.adopt();
@@ -825,4 +1075,4 @@ function writeJson(response, status, body) {
825
1075
  response.end(payload);
826
1076
  }
827
1077
  //#endregion
828
- export { Config, MANAGED_AUTH_PATH, MANAGED_PLUGIN_ID, MODEL_SYNC_PATH, SKILL_PLAZA_PATH, apply, currentModelSync, currentSkillPlaza, inject, name };
1078
+ export { Config, MANAGED_AUTH_PATH, MANAGED_PLUGIN_ID, MODEL_SYNC_PATH, SKILL_PLAZA_PATH, apply, currentModelSync, currentSettingsSync, currentSkillPlaza, inject, name };
@@ -14,20 +14,14 @@ export declare const en: {
14
14
  readonly modelsTitle: "Model configuration";
15
15
  readonly modelsDescription: "Share the models configured here with other users, and choose which of them they may use.";
16
16
  readonly modelsUnavailable: "Model configuration is unavailable until this workspace is connected to DataOps.";
17
- readonly syncTitle: "Share models with other users";
18
- readonly syncDescription: "Other users who have not configured their own models get the models configured here.";
19
- readonly syncToggle: "Share models with other users";
20
- readonly followTitle: "Using the shared model configuration";
21
- readonly followDescription: "The models shared by an administrator are available here. Change them and this workspace keeps your version instead.";
22
- readonly detachedTitle: "Using your own model configuration";
23
- readonly detachedDescription: "This workspace keeps the models configured here and no longer follows the shared configuration.";
24
- readonly resume: "Use the shared configuration again";
25
- readonly resuming: "Switching…";
26
- readonly noPublisher: "No administrator is sharing models right now.";
27
- readonly modelsListTitle: "Models other users may use";
28
- readonly modelsListDescription: "A model turned off here stays available in this workspace and is not offered to other users.";
29
- readonly modelShareLabel: "Available to other users";
30
- readonly modelsEmpty: "No models are configured in this workspace yet. Add them in Models settings first.";
17
+ readonly defaultsTitle: "Default configuration";
18
+ readonly defaultsDescription: "Publish the configuration this workspace holds — models, permissions, and general settings — as the values other users start from. Their workspace takes that copy once and may then change anything, and a change they make is never overwritten.";
19
+ readonly defaultsActionTitle: "Publish as the default";
20
+ readonly defaultsNotAdmin: "Only an administrator may publish the default configuration.";
21
+ readonly defaultsNeverPublished: "Not published yet.";
22
+ readonly defaultsPublishedAt: "Last published:";
23
+ readonly defaultsPublish: "Publish default configuration";
24
+ readonly defaultsPublishing: "Publishing…";
31
25
  readonly loadFailed: "The model configuration could not be loaded. Try again.";
32
26
  readonly plazaTitle: "Skill plaza";
33
27
  readonly plazaIntro: "Skills other users shared. Install one to use it in this workspace, or share a skill of your own.";
@@ -24,6 +24,23 @@ export interface ModelSyncStatus {
24
24
  canPublish: boolean;
25
25
  /** Models this workspace holds. */
26
26
  models: ModelSyncModel[];
27
+ /**
28
+ * Settings namespaces this deployment distributes.
29
+ *
30
+ * Present with the models in one response, so the two rows describe the same publication.
31
+ */
32
+ sections: DistributedSection[];
33
+ }
34
+ /** One settings namespace the deployment distributes. */
35
+ export interface DistributedSection {
36
+ /** DSH settings namespace, such as `permission`. */
37
+ ns: string;
38
+ /** Whether this workspace still takes the publisher's values for it. */
39
+ followState: 'following' | 'detached';
40
+ /** Whether adoption has applied values for it. */
41
+ applied: boolean;
42
+ /** Whether a plugin here owns the namespace, so stopping would affect this workspace. */
43
+ mounted: boolean;
27
44
  }
28
45
  /**
29
46
  * Read the current model sharing state.
@@ -2,6 +2,7 @@
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
3
  import z from '@deepseek-ai/schemastery';
4
4
  import { ModelSync } from './model-sync.ts';
5
+ import { SettingsSync, type SectionSyncState } from './settings-sync.ts';
5
6
  import { SkillPlaza } from './skill-plaza.ts';
6
7
  /** Cordis plugin name for the DataOps-managed integration. */
7
8
  export declare const name = "dataops-managed";
@@ -18,6 +19,8 @@ export declare const SKILL_PLAZA_PATH = "/integrations/dataops/skill-plaza";
18
19
  export declare function currentSkillPlaza(): SkillPlaza | undefined;
19
20
  /** The live model-sync instance, once this plugin activated. */
20
21
  export declare function currentModelSync(): ModelSync | undefined;
22
+ /** @returns The live instance, or undefined before this plugin activated. */
23
+ export declare function currentSettingsSync(): SettingsSync | undefined;
21
24
  /** Configuration for the DataOps-managed MCP connection. */
22
25
  export interface Config {
23
26
  /** DataOps browser/API origin reachable from the DSH Host. */
@@ -36,6 +39,20 @@ export interface Config {
36
39
  * their own models, which is a fact about this workspace.
37
40
  */
38
41
  modelSync: ModelSyncState;
42
+ /**
43
+ * Settings-distribution state this workspace keeps.
44
+ *
45
+ * Volatile, like the model-sync state beside it, and for the same reason: it records whether
46
+ * the user here wrote their own values, which is a fact about this workspace.
47
+ */
48
+ settingsSync: SectionSyncState;
49
+ /**
50
+ * Settings namespaces this deployment distributes to other workspaces.
51
+ *
52
+ * Empty by default: a deployment that publishes nothing offers no defaults, and a reader takes
53
+ * its own values. Naming a namespace here is what puts it in every publication.
54
+ */
55
+ distributedSettingsNamespaces: string[];
39
56
  }
40
57
  /** What this workspace remembers about sharing model configuration. */
41
58
  export interface ModelSyncState {
@@ -1,5 +1,6 @@
1
1
  /** Adopt, publish, and stop following the model configuration another workspace offers. */
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
+ import type { PublishedSection } from './settings-sync.ts';
3
4
  /** This plugin's own profile entry id, which owns the sync state. */
4
5
  export declare const SYNC_NAMESPACE = "dataops-managed";
5
6
  /** Whether this workspace follows the publisher, or keeps what its user wrote. */
@@ -26,6 +27,14 @@ export interface SyncConfig {
26
27
  baseUrl: string;
27
28
  /** DSH credential reference holding the current DataOps access JWT. */
28
29
  credentialRef: string;
30
+ /**
31
+ * Collect the settings sections this workspace offers to others.
32
+ *
33
+ * Injected rather than read here because which namespaces travel is a deployment decision:
34
+ * this class carries a publication, and the deployment decides what belongs in one.
35
+ * @returns The sections to publish, in the order they should be applied.
36
+ */
37
+ collectSections?: () => Promise<PublishedSection[]>;
29
38
  }
30
39
  /** What the settings UI reads to describe the current state. */
31
40
  export interface SyncStatus {
@@ -133,6 +142,15 @@ export declare class ModelSync {
133
142
  * survives the next start.
134
143
  */
135
144
  detach(): Promise<void>;
145
+ /**
146
+ * Read the settings sections the current publication offers.
147
+ *
148
+ * Separate from `adopt`, which owns the models: a workspace adopts models and settings from one
149
+ * publication but keeps separate bookkeeping for each, because a user who changed the permission
150
+ * default has not thereby chosen their own models.
151
+ * @returns The offered sections, or an empty list when nobody publishes or none are offered.
152
+ */
153
+ readPublishedSections(): Promise<PublishedSection[]>;
136
154
  /**
137
155
  * Follow the publisher again, adopting its models on the next pass.
138
156
  */
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Settings the workspace takes as defaults from an administrator's publication.
3
+ *
4
+ * The model sync owns the models. This module owns every other namespace the deployment chooses
5
+ * to distribute, and it follows the same contract: a workspace takes the published values once,
6
+ * and from the first value its user writes it keeps its own and is never overwritten again.
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ /** One settings namespace offered by a publication. */
10
+ export interface PublishedSection {
11
+ /** DSH settings namespace, such as `permission`. */
12
+ ns: string;
13
+ /** The section's values at publication time. */
14
+ value: Record<string, unknown>;
15
+ }
16
+ /** Whether this workspace still follows the publisher for one namespace. */
17
+ export type SectionFollowState = 'following' | 'detached';
18
+ /** One distributed namespace as the settings UI reads it. */
19
+ export interface DistributedSectionStatus {
20
+ /** DSH settings namespace. */
21
+ ns: string;
22
+ /** Whether this workspace still takes the publisher's values for it. */
23
+ followState: SectionFollowState;
24
+ /** Whether adoption has applied values for it. */
25
+ applied: boolean;
26
+ /** Whether a plugin in this workspace owns the namespace, so applying it would do something. */
27
+ mounted: boolean;
28
+ }
29
+ /** Per-namespace bookkeeping this workspace keeps. */
30
+ export interface SectionSyncRecord {
31
+ /** Whether the user here wrote this namespace, so adoption stops for it. */
32
+ detached: boolean;
33
+ /** A fingerprint of what adoption last wrote, so a later edit can be told apart from it. */
34
+ adoptedFingerprint: string | null;
35
+ }
36
+ /** What this workspace remembers about distributed settings. */
37
+ export interface SectionSyncState {
38
+ /** Bookkeeping keyed by settings namespace. */
39
+ sections: Record<string, SectionSyncRecord>;
40
+ }
41
+ /** Default state before this workspace stored anything. */
42
+ export declare const SECTION_SYNC_DEFAULT: SectionSyncState;
43
+ /** Read and write this workspace's section-sync state. */
44
+ export interface SectionSyncStore {
45
+ /** @returns The stored state, or defaults before anything was stored. */
46
+ read(): SectionSyncState;
47
+ /** @param next - State to store. */
48
+ write(next: SectionSyncState): Promise<void>;
49
+ }
50
+ /**
51
+ * A stable rendering of one section's values.
52
+ *
53
+ * Key order is normalized so two reads of the same values match, and a publication that only
54
+ * reordered its keys does not read as a change the user made.
55
+ * @param value - Section values.
56
+ * @returns A fingerprint comparable across reads.
57
+ */
58
+ export declare function fingerprintSection(value: Record<string, unknown>): string;
59
+ /**
60
+ * Own the distributed settings one workspace keeps.
61
+ *
62
+ * The state lives in this workspace rather than in DataOps because whether a user has written
63
+ * their own values is a fact about this workspace, and keeping it here is what makes the promise
64
+ * hold across restarts: a user who chose their own settings is not adopted again.
65
+ */
66
+ export declare class SettingsSync {
67
+ private readonly ctx;
68
+ private readonly store;
69
+ constructor(ctx: Context, store: SectionSyncStore);
70
+ /**
71
+ * Read one namespace as this workspace currently holds it.
72
+ * @param ns - Settings namespace to read.
73
+ * @returns The values and revision, or undefined when no plugin owns that namespace.
74
+ */
75
+ private readSection;
76
+ /** Read this workspace's per-namespace bookkeeping. */
77
+ private recordOf;
78
+ /**
79
+ * Take the published values for every namespace this workspace has not already diverged from.
80
+ *
81
+ * A namespace the user has written is skipped and marked detached, so a local edit is never
82
+ * replaced. A namespace whose current values match what adoption last wrote is still following,
83
+ * so the publisher's newer values replace them. Everything else is a default a user may change.
84
+ * @param sections - Sections the publisher offers.
85
+ * @returns Namespaces that were written, for logging.
86
+ */
87
+ adopt(sections: readonly PublishedSection[]): Promise<string[]>;
88
+ /**
89
+ * Describe the distributed settings for the settings UI.
90
+ *
91
+ * Reports every namespace the deployment offers, whether this workspace still follows the
92
+ * publisher for it, and whether a plugin here owns it. A namespace no plugin owns cannot be
93
+ * applied, so a panel marks that row rather than offering a control that would do nothing.
94
+ * @param namespaces - Namespaces the deployment distributes.
95
+ * @returns One entry per namespace, in the order given.
96
+ */
97
+ status(namespaces: readonly string[]): DistributedSectionStatus[];
98
+ /**
99
+ * Stop following the publisher for one namespace and keep what this workspace holds now.
100
+ * @param ns - Settings namespace to detach.
101
+ */
102
+ detach(ns: string): Promise<void>;
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-dataops-managed",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "DataOps JWT intake and credential-backed MCP tools for managed DeepSeek Harness workspaces.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",