@ran-sh/dsh-crew 0.5.7 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -179,7 +179,16 @@ function componentState(status, keys, { aligned = false } = {}) {
179
179
  if (evidence.some((value) => typeof value !== "boolean")) return READINESS_STATES.UNKNOWN;
180
180
  return evidence.every(Boolean) && (!aligned || status.components.target_alignment === true) ? READINESS_STATES.READY : READINESS_STATES.DEGRADED;
181
181
  }
182
- function runtimeState(runtime) {
182
+ function runtimeState(runtime, readinessSnapshot) {
183
+ if (readinessSnapshot && typeof readinessSnapshot === "object") {
184
+ const identity = readinessSnapshot.runtime;
185
+ if (!(identity?.execution_plane === "hub-3210" && identity.profile === "dsh-crew" && Number(identity.listen_port) === 3210 && typeof identity.runtime_id === "string" && identity.runtime_id.trim())) return READINESS_STATES.UNAVAILABLE;
186
+ const row = Array.isArray(readinessSnapshot.readiness_matrix?.rows) ? readinessSnapshot.readiness_matrix.rows.find((entry) => entry?.id === "hub_compatibility") : void 0;
187
+ if (row?.status === "PASS") return READINESS_STATES.READY;
188
+ if (row?.status === "FAIL") return READINESS_STATES.UNAVAILABLE;
189
+ if (row?.status === "NOT_RUN" || row?.status === "SKIP") return READINESS_STATES.DEGRADED;
190
+ return READINESS_STATES.UNKNOWN;
191
+ }
183
192
  if (runtime === void 0) return READINESS_STATES.UNKNOWN;
184
193
  if (runtime === null) return READINESS_STATES.UNAVAILABLE;
185
194
  return runtime?.ok === true && runtime.service === "dsh-crew-hub" ? READINESS_STATES.READY : READINESS_STATES.DEGRADED;
@@ -190,7 +199,7 @@ function bridgeState(surface) {
190
199
  return READINESS_STATES.UNKNOWN;
191
200
  }
192
201
  /** Project only structured, non-secret installer/runtime evidence. */
193
- function projectHostReadiness({ installStatus, runtime, surface } = {}) {
202
+ function projectHostReadiness({ installStatus, runtime, surface, readinessSnapshot } = {}) {
194
203
  const codex = installStatus?.codex;
195
204
  const claude = installStatus?.claude;
196
205
  const zcode = installStatus?.zcode;
@@ -230,7 +239,7 @@ function projectHostReadiness({ installStatus, runtime, surface } = {}) {
230
239
  },
231
240
  {
232
241
  id: "crew_harness",
233
- state: runtimeState(runtime),
242
+ state: runtimeState(runtime, readinessSnapshot),
234
243
  detail: runtime?.runtime_version ?? null
235
244
  },
236
245
  {
@@ -434,7 +443,7 @@ const COPY = {
434
443
  "follow-dsh": "跟随 Harness · 使用各档独立的模型优先级",
435
444
  "deepseek-official": "DeepSeek Official · 使用旧版固定模型路由"
436
445
  },
437
- workerProviderHint: "仅作用于 Hub 模式;Standalone 始终用 deepseek-official + DEEPSEEK_API_KEY。",
446
+ workerProviderHint: "仅作用于 Hub 模式;Standalone 仅保留为旧版迁移元数据,不是生产执行路径。",
438
447
  workerModels: "Worker 模型",
439
448
  refreshHarnessModels: "刷新 Harness 模型",
440
449
  refreshingModels: "刷新中…",
@@ -503,11 +512,23 @@ const COPY = {
503
512
  providerDeletePlan: "生成删除计划",
504
513
  providerDelete: "确认删除",
505
514
  providerPlanReady: "计划已生成;再次确认后才会修改 3210 配置。",
515
+ providerRollback: "回滚",
516
+ providerRollbackConfirm: "按已保存事务恢复这个 Harness Provider 及原有路由?",
517
+ providerRollbackUnavailable: "没有可验证的回滚事务",
506
518
  providerReplacement: "替换 Harness 默认 Provider(输入 Provider id)",
507
519
  providerDeleteConfirm: "确认按计划删除这个 Harness Provider?会清理路由引用并重启 3210。",
508
520
  providerDeleteBlocked: "当前 Provider 不能删除(内置、在用、已删除或缺少可用替换)。",
509
521
  providerLifecycleError: "Provider 生命周期操作失败",
510
522
  providerNoInventory: "暂时无法读取 3210 Provider inventory。",
523
+ credentialReferences: "Credential 引用",
524
+ credentialReferenceHint: "仅显示引用名、归属和孤儿状态;不会读取或删除密钥。",
525
+ credentialOrphan: "孤儿 · 可单独申请清理",
526
+ credentialInUse: (count) => `使用中 · ${count} 个 Provider`,
527
+ credentialPurgePlan: "申请清理",
528
+ credentialPurgeConfirm: "这是不可恢复的 Crew-owned 凭据清理,确认继续?",
529
+ credentialPurging: "清理中…",
530
+ credentialPurgeUnavailable: "凭据清理不可用",
531
+ credentialPurgeUnverified: "已执行清理,但验证失败;引用仍保留用于恢复检查",
511
532
  customProviders: "自定义 Provider",
512
533
  addProvider: "+ 添加 Provider",
513
534
  noCustomProviders: "暂无。添加后会出现在上方的视觉 / 生图 provider 选择里。",
@@ -745,7 +766,7 @@ const COPY = {
745
766
  "follow-dsh": "Follow Harness · use each tier's ordered model priority",
746
767
  "deepseek-official": "DeepSeek Official · use legacy fixed model routing"
747
768
  },
748
- workerProviderHint: "Applies to Hub workers only; Standalone always uses deepseek-official + DEEPSEEK_API_KEY.",
769
+ workerProviderHint: "Applies to Hub workers only; Standalone is legacy migration metadata only and is not a production execution path.",
749
770
  workerModels: "Worker Models",
750
771
  refreshHarnessModels: "Refresh Harness Models",
751
772
  refreshingModels: "Refreshing…",
@@ -814,11 +835,23 @@ const COPY = {
814
835
  providerDeletePlan: "Plan deletion",
815
836
  providerDelete: "Confirm delete",
816
837
  providerPlanReady: "Plan ready; a second confirmation is required before changing 3210 config.",
838
+ providerRollback: "Rollback",
839
+ providerRollbackConfirm: "Restore this Harness Provider and its previous routing from the saved transaction?",
840
+ providerRollbackUnavailable: "No verified rollback transaction is available",
817
841
  providerReplacement: "Replacement Harness Default provider id",
818
842
  providerDeleteConfirm: "Delete this Harness Provider per the plan? Routing refs will be scrubbed and 3210 restarted.",
819
843
  providerDeleteBlocked: "This Provider cannot be deleted (built-in, in use, already absent, or missing a valid replacement).",
820
844
  providerLifecycleError: "Provider lifecycle operation failed",
821
845
  providerNoInventory: "3210 Provider inventory is temporarily unavailable.",
846
+ credentialReferences: "Credential references",
847
+ credentialReferenceHint: "Names, ownership, and orphan status only; values are never read or deleted.",
848
+ credentialOrphan: "orphan · separate purge approval required",
849
+ credentialInUse: (count) => `in use · ${count} provider(s)`,
850
+ credentialPurgePlan: "Purge…",
851
+ credentialPurgeConfirm: "This irreversibly removes a Crew-owned credential. Continue?",
852
+ credentialPurging: "Purging…",
853
+ credentialPurgeUnavailable: "Credential purge unavailable",
854
+ credentialPurgeUnverified: "Purge ran but verification failed; the reference remains visible for recovery",
822
855
  customProviders: "Custom providers",
823
856
  addProvider: "+ Add provider",
824
857
  noCustomProviders: "None yet. Added providers appear in the vision / image-gen selects above.",
@@ -1406,6 +1439,9 @@ function WorkersPanel({ ctx }) {
1406
1439
  const [modelCatalogBusy, setModelCatalogBusy] = (0, react.useState)(false);
1407
1440
  const [providerInventory, setProviderInventory] = (0, react.useState)(null);
1408
1441
  const [providerHealth, setProviderHealth] = (0, react.useState)([]);
1442
+ const [credentialRefs, setCredentialRefs] = (0, react.useState)([]);
1443
+ const [credentialUnverified, setCredentialUnverified] = (0, react.useState)([]);
1444
+ const [credentialLifecycleBusy, setCredentialLifecycleBusy] = (0, react.useState)(null);
1409
1445
  const [providerInventoryError, setProviderInventoryError] = (0, react.useState)("");
1410
1446
  const [providerLifecycleBusy, setProviderLifecycleBusy] = (0, react.useState)(null);
1411
1447
  const [modelPickerTier, setModelPickerTier] = (0, react.useState)(null);
@@ -1414,6 +1450,7 @@ function WorkersPanel({ ctx }) {
1414
1450
  const [expandedSections, setExpandedSections] = (0, react.useState)(() => readSectionState(sectionStorage()));
1415
1451
  const [surface, setSurface] = (0, react.useState)("detecting");
1416
1452
  const [runtimeInfo, setRuntimeInfo] = (0, react.useState)(void 0);
1453
+ const [readinessSnapshot, setReadinessSnapshot] = (0, react.useState)(void 0);
1417
1454
  const toggleSection = (sectionId) => {
1418
1455
  setExpandedSections((current) => ({
1419
1456
  ...current,
@@ -1494,13 +1531,15 @@ function WorkersPanel({ ctx }) {
1494
1531
  }, [detectSurface]);
1495
1532
  const refreshAll = (0, react.useCallback)(async () => {
1496
1533
  try {
1497
- const [j, s, c, pr, pi, ph] = await Promise.all([
1534
+ const [j, s, c, pr, pi, ph, cr, ext] = await Promise.all([
1498
1535
  get("/jobs"),
1499
1536
  get("/install/status"),
1500
1537
  get("/config"),
1501
1538
  get("/presets"),
1502
1539
  get("/providers").catch(() => null),
1503
- get("/provider-health").catch(() => null)
1540
+ get("/provider-health").catch(() => null),
1541
+ get("/credential-references").catch(() => null),
1542
+ get("/extension").catch(() => null)
1504
1543
  ]);
1505
1544
  if (j.ok) setJobs(j.jobs ?? []);
1506
1545
  if (s.ok) setStatus(s.status);
@@ -1514,6 +1553,11 @@ function WorkersPanel({ ctx }) {
1514
1553
  }))]);
1515
1554
  if (pi?.ok) setProviderInventory(pi);
1516
1555
  if (ph?.ok) setProviderHealth(ph.health ?? []);
1556
+ if (cr?.ok) {
1557
+ setCredentialRefs(cr.records ?? []);
1558
+ setCredentialUnverified(cr.unverified_purges ?? []);
1559
+ }
1560
+ if (ext?.ok) setReadinessSnapshot(ext.extension?.readiness_snapshot ?? void 0);
1517
1561
  } catch {}
1518
1562
  }, [get]);
1519
1563
  (0, react.useEffect)(() => {
@@ -1547,14 +1591,59 @@ function WorkersPanel({ ctx }) {
1547
1591
  const refreshProviderInventory = (0, react.useCallback)(async () => {
1548
1592
  setProviderInventoryError("");
1549
1593
  try {
1550
- const [result, health] = await Promise.all([get("/providers"), get("/provider-health").catch(() => null)]);
1594
+ const [result, health, refs] = await Promise.all([
1595
+ get("/providers"),
1596
+ get("/provider-health").catch(() => null),
1597
+ get("/credential-references").catch(() => null)
1598
+ ]);
1551
1599
  if (!result.ok) throw new Error(result.error ?? copy.providerNoInventory);
1552
1600
  setProviderInventory(result);
1553
1601
  if (health?.ok) setProviderHealth(health.health ?? []);
1602
+ if (refs?.ok) {
1603
+ setCredentialRefs(refs.records ?? []);
1604
+ setCredentialUnverified(refs.unverified_purges ?? []);
1605
+ }
1554
1606
  } catch (error) {
1555
1607
  setProviderInventoryError(error?.message ?? copy.providerNoInventory);
1556
1608
  }
1557
1609
  }, [get, copy]);
1610
+ const purgeCredentialRef = (0, react.useCallback)(async (ref) => {
1611
+ if (ref?.ownership !== "crew-owned" || ref?.orphan !== true || ref?.purge_capability !== "eligible") return;
1612
+ const referenceId = String(ref.reference_id ?? "");
1613
+ if (!referenceId || !window.confirm(copy.credentialPurgeConfirm)) return;
1614
+ setCredentialLifecycleBusy(referenceId);
1615
+ setNotice(copy.working);
1616
+ try {
1617
+ const encoded = encodeURIComponent(referenceId);
1618
+ const planned = await post(`/credential-references/${encoded}/purge-plan`, {});
1619
+ if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.credentialPurgeUnavailable);
1620
+ if (!window.confirm(`${copy.credentialPurgeConfirm}\n\n${referenceId}`)) return;
1621
+ const result = await readJson(await fetch(withLang(`/credential-references/${encoded}`), {
1622
+ method: "DELETE",
1623
+ headers: { "content-type": "application/json" },
1624
+ body: JSON.stringify({
1625
+ plan_id: planned.plan.plan_id,
1626
+ expected_revision: planned.plan.expected_revision,
1627
+ confirm: true,
1628
+ lang: locale === "zh" ? "zh" : "en"
1629
+ })
1630
+ }), `/credential-references/${encoded}`);
1631
+ if (!result.ok || result.state !== "VERIFIED") throw new Error(result.code ?? copy.credentialPurgeUnavailable);
1632
+ setNotice(copy.saved);
1633
+ await refreshProviderInventory();
1634
+ } catch (error) {
1635
+ setNotice(String(error?.message ?? error));
1636
+ } finally {
1637
+ setCredentialLifecycleBusy(null);
1638
+ }
1639
+ }, [
1640
+ copy,
1641
+ locale,
1642
+ post,
1643
+ readJson,
1644
+ refreshProviderInventory,
1645
+ withLang
1646
+ ]);
1558
1647
  (0, react.useEffect)(() => {
1559
1648
  if (surface === CREW_UI_SURFACES.OFFICIAL) refreshHarnessModels();
1560
1649
  }, [surface, refreshHarnessModels]);
@@ -1922,6 +2011,43 @@ function WorkersPanel({ ctx }) {
1922
2011
  setProviderLifecycleBusy(null);
1923
2012
  }
1924
2013
  };
2014
+ const rollbackHarnessProvider = async (record, transactionId) => {
2015
+ if (!record || !transactionId || record.desired_state !== "absent") {
2016
+ setNotice(copy.providerRollbackUnavailable);
2017
+ return;
2018
+ }
2019
+ if (!window.confirm(copy.providerRollbackConfirm)) return;
2020
+ setProviderLifecycleBusy(`rollback:${record.id}`);
2021
+ setNotice(copy.working);
2022
+ try {
2023
+ const encoded = encodeURIComponent(record.id);
2024
+ const result = await post(`/providers/${encoded}/rollback`, {
2025
+ transaction_id: transactionId,
2026
+ confirm: true
2027
+ });
2028
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
2029
+ if (result.restart_required === true && result.state === "ROLLBACK_PENDING") {
2030
+ const restartPath = "http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart";
2031
+ const restarted = await readJson(await fetch(restartPath, {
2032
+ method: "POST",
2033
+ headers: { "content-type": "application/json" },
2034
+ body: JSON.stringify({ confirm: true })
2035
+ }), restartPath);
2036
+ if (!restarted.ok) throw new Error(restarted.code ?? restarted.error ?? copy.providerLifecycleError);
2037
+ const verified = await post(`/providers/${encoded}/verify-rollback`, {
2038
+ transaction_id: transactionId,
2039
+ confirm: true
2040
+ });
2041
+ if (!verified.ok || verified.state !== "ROLLED_BACK") throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
2042
+ } else if (result.state !== "ROLLED_BACK") throw new Error(result.code ?? copy.providerLifecycleError);
2043
+ setNotice(copy.saved);
2044
+ await refreshProviderInventory();
2045
+ } catch (error) {
2046
+ setNotice(String(error?.message ?? error));
2047
+ } finally {
2048
+ setProviderLifecycleBusy(null);
2049
+ }
2050
+ };
1925
2051
  /** Card: title + one-line description header, then its fields. */
1926
2052
  const block = (text, fields, gridded = true) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1927
2053
  style: S.block,
@@ -2021,7 +2147,8 @@ function WorkersPanel({ ctx }) {
2021
2147
  const hostReadiness = projectHostReadiness({
2022
2148
  installStatus: status,
2023
2149
  runtime: runtimeInfo,
2024
- surface
2150
+ surface,
2151
+ readinessSnapshot
2025
2152
  });
2026
2153
  if (!currentSurfaceResponsibilities.fullControlPlane) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MinimalCrewPanel, {
2027
2154
  locale,
@@ -3463,6 +3590,7 @@ function WorkersPanel({ ctx }) {
3463
3590
  (providerInventory?.records ?? []).map((record) => {
3464
3591
  const blocked = record.ownership === "harness" || record.desired_state === "absent" || Number(record.references?.active_jobs ?? 0) > 0;
3465
3592
  const lifecycle = record.desired_state === "absent" ? "absent" : record.lifecycle?.enabled === false ? "disabled" : record.lifecycle?.catalogued ? "catalogued" : "configured";
3593
+ const rollbackTransaction = (providerInventory?.lifecycle_transactions ?? []).find((entry) => entry.provider_id === record.id && ["VERIFIED", "RESTART_PENDING"].includes(entry.state));
3466
3594
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3467
3595
  style: {
3468
3596
  display: "flex",
@@ -3507,9 +3635,95 @@ function WorkersPanel({ ctx }) {
3507
3635
  deleteHarnessProvider(record);
3508
3636
  },
3509
3637
  children: providerLifecycleBusy === record.id ? copy.working : copy.providerDeletePlan
3638
+ }),
3639
+ record.desired_state === "absent" && rollbackTransaction && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3640
+ type: "button",
3641
+ style: {
3642
+ ...S.btn,
3643
+ padding: "2px 8px"
3644
+ },
3645
+ disabled: providerLifecycleBusy !== null,
3646
+ onClick: () => {
3647
+ rollbackHarnessProvider(record, rollbackTransaction.transaction_id);
3648
+ },
3649
+ children: providerLifecycleBusy === `rollback:${record.id}` ? copy.working : copy.providerRollback
3510
3650
  })
3511
3651
  ]
3512
3652
  }, record.id);
3653
+ }),
3654
+ (credentialRefs.length > 0 || credentialUnverified.length > 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3655
+ style: {
3656
+ marginTop: 10,
3657
+ borderTop: "1px solid rgba(128,128,128,0.16)",
3658
+ paddingTop: 8
3659
+ },
3660
+ children: [
3661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3662
+ style: {
3663
+ fontWeight: 600,
3664
+ fontSize: 12.5
3665
+ },
3666
+ children: copy.credentialReferences
3667
+ }),
3668
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3669
+ style: {
3670
+ fontSize: 11,
3671
+ opacity: .6,
3672
+ margin: "3px 0 7px"
3673
+ },
3674
+ children: copy.credentialReferenceHint
3675
+ }),
3676
+ credentialUnverified.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3677
+ style: {
3678
+ color: "#c55",
3679
+ fontSize: 11.5
3680
+ },
3681
+ children: [
3682
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3683
+ style: S.mono,
3684
+ children: entry.reference_id
3685
+ }),
3686
+ " · ",
3687
+ copy.credentialPurgeUnverified
3688
+ ]
3689
+ }, `unverified:${entry.reference_id}`)),
3690
+ credentialRefs.map((ref) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3691
+ style: {
3692
+ display: "flex",
3693
+ alignItems: "center",
3694
+ gap: 8,
3695
+ fontSize: 11.5,
3696
+ flexWrap: "wrap"
3697
+ },
3698
+ children: [
3699
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3700
+ style: S.mono,
3701
+ children: ref.reference_id
3702
+ }),
3703
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3704
+ style: { opacity: .65 },
3705
+ children: ref.ownership
3706
+ }),
3707
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3708
+ style: { color: ref.orphan ? "#c98735" : void 0 },
3709
+ children: ref.orphan ? copy.credentialOrphan : copy.credentialInUse(ref.consumer_count ?? 0)
3710
+ }),
3711
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
3712
+ ref.ownership === "crew-owned" && ref.orphan === true && ref.purge_capability === "eligible" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3713
+ type: "button",
3714
+ style: {
3715
+ ...S.btn,
3716
+ padding: "2px 8px"
3717
+ },
3718
+ disabled: credentialLifecycleBusy !== null,
3719
+ onClick: () => {
3720
+ purgeCredentialRef(ref);
3721
+ },
3722
+ children: credentialLifecycleBusy === ref.reference_id ? copy.credentialPurging : copy.credentialPurgePlan
3723
+ })
3724
+ ]
3725
+ }, ref.reference_id))
3726
+ ]
3513
3727
  })
3514
3728
  ]
3515
3729
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "0.5.7",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -15,7 +15,20 @@ function componentState(status, keys, { aligned = false } = {}) {
15
15
  return ready ? READINESS_STATES.READY : READINESS_STATES.DEGRADED;
16
16
  }
17
17
 
18
- function runtimeState(runtime) {
18
+ function runtimeState(runtime, readinessSnapshot) {
19
+ if (readinessSnapshot && typeof readinessSnapshot === 'object') {
20
+ const identity = readinessSnapshot.runtime;
21
+ if (!(identity?.execution_plane === 'hub-3210' && identity.profile === 'dsh-crew'
22
+ && Number(identity.listen_port) === 3210 && typeof identity.runtime_id === 'string' && identity.runtime_id.trim())) {
23
+ return READINESS_STATES.UNAVAILABLE;
24
+ }
25
+ const row = Array.isArray(readinessSnapshot.readiness_matrix?.rows)
26
+ ? readinessSnapshot.readiness_matrix.rows.find((entry) => entry?.id === 'hub_compatibility') : undefined;
27
+ if (row?.status === 'PASS') return READINESS_STATES.READY;
28
+ if (row?.status === 'FAIL') return READINESS_STATES.UNAVAILABLE;
29
+ if (row?.status === 'NOT_RUN' || row?.status === 'SKIP') return READINESS_STATES.DEGRADED;
30
+ return READINESS_STATES.UNKNOWN;
31
+ }
19
32
  if (runtime === undefined) return READINESS_STATES.UNKNOWN;
20
33
  if (runtime === null) return READINESS_STATES.UNAVAILABLE;
21
34
  return runtime?.ok === true && runtime.service === 'dsh-crew-hub'
@@ -30,7 +43,7 @@ function bridgeState(surface) {
30
43
  }
31
44
 
32
45
  /** Project only structured, non-secret installer/runtime evidence. */
33
- export function projectHostReadiness({ installStatus, runtime, surface } = {}) {
46
+ export function projectHostReadiness({ installStatus, runtime, surface, readinessSnapshot } = {}) {
34
47
  const codex = installStatus?.codex;
35
48
  const claude = installStatus?.claude;
36
49
  const zcode = installStatus?.zcode;
@@ -40,7 +53,7 @@ export function projectHostReadiness({ installStatus, runtime, surface } = {}) {
40
53
  { id: 'ds_reviewer', state: componentState(codex, ['reviewer_role'], { aligned: true }) },
41
54
  { id: 'claude_plugin', state: componentState(claude, ['enabled', 'marketplace', 'snapshot', 'permissions']) },
42
55
  { id: 'zcode_mcp', state: componentState(zcode, ['mcp', 'policy', 'worker_agent', 'reviewer_agent', 'config_prompt', 'status_prompt', 'ownership']) },
43
- { id: 'crew_harness', state: runtimeState(runtime), detail: runtime?.runtime_version ?? null },
56
+ { id: 'crew_harness', state: runtimeState(runtime, readinessSnapshot), detail: runtime?.runtime_version ?? null },
44
57
  { id: 'official_bridge', state: bridgeState(surface) },
45
58
  ];
46
59
  }
@@ -75,7 +75,7 @@ const COPY = {
75
75
  statePresetHint: '切到 Custom 可单独配置各档状态。',
76
76
  workerProvider: 'Worker Provider',
77
77
  workerProviderDesc: { 'follow-dsh': '跟随 Harness · 使用各档独立的模型优先级', 'deepseek-official': 'DeepSeek Official · 使用旧版固定模型路由' },
78
- workerProviderHint: '仅作用于 Hub 模式;Standalone 始终用 deepseek-official + DEEPSEEK_API_KEY。',
78
+ workerProviderHint: '仅作用于 Hub 模式;Standalone 仅保留为旧版迁移元数据,不是生产执行路径。',
79
79
  workerModels: 'Worker 模型', refreshHarnessModels: '刷新 Harness 模型', refreshingModels: '刷新中…',
80
80
  modelPriority: '模型优先级', addPriorityModel: '+ 添加优先模型', modelSearch: '搜索 Provider 或 Model…',
81
81
  harnessDefault: 'Harness 默认模型', providerUnavailable: 'Provider 不可用', notAdvertised: '当前未列出',
@@ -105,10 +105,12 @@ const COPY = {
105
105
  visionProvider: '视觉 provider', visionModel: '视觉模型', imagegenProvider: '生图 provider',
106
106
  harnessProviders: 'Harness Providers', harnessProviderHint: '3210 Crew Harness 的真实 Provider 注册与生命周期;不是视觉 / 生图适配器。',
107
107
  providerState: (state: string) => `状态:${state}`, providerModels: (count: number) => `${count} 个模型`, providerJobs: (count: number) => `${count} 个运行任务`,
108
- providerDeletePlan: '生成删除计划', providerDelete: '确认删除', providerPlanReady: '计划已生成;再次确认后才会修改 3210 配置。',
108
+ providerDeletePlan: '生成删除计划', providerDelete: '确认删除', providerPlanReady: '计划已生成;再次确认后才会修改 3210 配置。', providerRollback: '回滚', providerRollbackConfirm: '按已保存事务恢复这个 Harness Provider 及原有路由?', providerRollbackUnavailable: '没有可验证的回滚事务',
109
109
  providerReplacement: '替换 Harness 默认 Provider(输入 Provider id)', providerDeleteConfirm: '确认按计划删除这个 Harness Provider?会清理路由引用并重启 3210。',
110
110
  providerDeleteBlocked: '当前 Provider 不能删除(内置、在用、已删除或缺少可用替换)。', providerLifecycleError: 'Provider 生命周期操作失败',
111
111
  providerNoInventory: '暂时无法读取 3210 Provider inventory。',
112
+ credentialReferences: 'Credential 引用', credentialReferenceHint: '仅显示引用名、归属和孤儿状态;不会读取或删除密钥。',
113
+ credentialOrphan: '孤儿 · 可单独申请清理', credentialInUse: (count: number) => `使用中 · ${count} 个 Provider`, credentialPurgePlan: '申请清理', credentialPurgeConfirm: '这是不可恢复的 Crew-owned 凭据清理,确认继续?', credentialPurging: '清理中…', credentialPurgeUnavailable: '凭据清理不可用', credentialPurgeUnverified: '已执行清理,但验证失败;引用仍保留用于恢复检查',
112
114
  customProviders: '自定义 Provider', addProvider: '+ 添加 Provider',
113
115
  noCustomProviders: '暂无。添加后会出现在上方的视觉 / 生图 provider 选择里。',
114
116
  providerName: '名称', modelsField: '模型列表(逗号分隔)',
@@ -212,7 +214,7 @@ const COPY = {
212
214
  statePresetHint: 'Switch to Custom to configure each tier state separately.',
213
215
  workerProvider: 'Worker Provider',
214
216
  workerProviderDesc: { 'follow-dsh': 'Follow Harness · use each tier\'s ordered model priority', 'deepseek-official': 'DeepSeek Official · use legacy fixed model routing' },
215
- workerProviderHint: 'Applies to Hub workers only; Standalone always uses deepseek-official + DEEPSEEK_API_KEY.',
217
+ workerProviderHint: 'Applies to Hub workers only; Standalone is legacy migration metadata only and is not a production execution path.',
216
218
  workerModels: 'Worker Models', refreshHarnessModels: 'Refresh Harness Models', refreshingModels: 'Refreshing…',
217
219
  modelPriority: 'Model Priority', addPriorityModel: '+ Add Priority Model', modelSearch: 'Search Provider or Model…',
218
220
  harnessDefault: 'Harness Default', providerUnavailable: 'Provider unavailable', notAdvertised: 'Currently not advertised',
@@ -242,10 +244,12 @@ const COPY = {
242
244
  visionProvider: 'Vision provider', visionModel: 'Vision model', imagegenProvider: 'Image-gen provider',
243
245
  harnessProviders: 'Harness Providers', harnessProviderHint: 'Live Provider registration and lifecycle in the 3210 Crew Harness; separate from vision / image-gen adapters.',
244
246
  providerState: (state: string) => `State: ${state}`, providerModels: (count: number) => `${count} models`, providerJobs: (count: number) => `${count} running jobs`,
245
- providerDeletePlan: 'Plan deletion', providerDelete: 'Confirm delete', providerPlanReady: 'Plan ready; a second confirmation is required before changing 3210 config.',
247
+ providerDeletePlan: 'Plan deletion', providerDelete: 'Confirm delete', providerPlanReady: 'Plan ready; a second confirmation is required before changing 3210 config.', providerRollback: 'Rollback', providerRollbackConfirm: 'Restore this Harness Provider and its previous routing from the saved transaction?', providerRollbackUnavailable: 'No verified rollback transaction is available',
246
248
  providerReplacement: 'Replacement Harness Default provider id', providerDeleteConfirm: 'Delete this Harness Provider per the plan? Routing refs will be scrubbed and 3210 restarted.',
247
249
  providerDeleteBlocked: 'This Provider cannot be deleted (built-in, in use, already absent, or missing a valid replacement).', providerLifecycleError: 'Provider lifecycle operation failed',
248
250
  providerNoInventory: '3210 Provider inventory is temporarily unavailable.',
251
+ credentialReferences: 'Credential references', credentialReferenceHint: 'Names, ownership, and orphan status only; values are never read or deleted.',
252
+ credentialOrphan: 'orphan · separate purge approval required', credentialInUse: (count: number) => `in use · ${count} provider(s)`, credentialPurgePlan: 'Purge…', credentialPurgeConfirm: 'This irreversibly removes a Crew-owned credential. Continue?', credentialPurging: 'Purging…', credentialPurgeUnavailable: 'Credential purge unavailable', credentialPurgeUnverified: 'Purge ran but verification failed; the reference remains visible for recovery',
249
253
  customProviders: 'Custom providers', addProvider: '+ Add provider',
250
254
  noCustomProviders: 'None yet. Added providers appear in the vision / image-gen selects above.',
251
255
  providerName: 'Name', modelsField: 'Models (comma-separated)',
@@ -526,6 +530,9 @@ function WorkersPanel({ ctx }: { ctx: any }) {
526
530
  const [modelCatalogBusy, setModelCatalogBusy] = useState(false);
527
531
  const [providerInventory, setProviderInventory] = useState<any>(null);
528
532
  const [providerHealth, setProviderHealth] = useState<any[]>([]);
533
+ const [credentialRefs, setCredentialRefs] = useState<any[]>([]);
534
+ const [credentialUnverified, setCredentialUnverified] = useState<any[]>([]);
535
+ const [credentialLifecycleBusy, setCredentialLifecycleBusy] = useState<string | null>(null);
529
536
  const [providerInventoryError, setProviderInventoryError] = useState('');
530
537
  const [providerLifecycleBusy, setProviderLifecycleBusy] = useState<string | null>(null);
531
538
  const [modelPickerTier, setModelPickerTier] = useState<'flash' | 'pro' | null>(null);
@@ -534,6 +541,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
534
541
  const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => readSectionState(sectionStorage()));
535
542
  const [surface, setSurface] = useState<string>('detecting');
536
543
  const [runtimeInfo, setRuntimeInfo] = useState<any>(undefined);
544
+ const [readinessSnapshot, setReadinessSnapshot] = useState<any>(undefined);
537
545
 
538
546
  const toggleSection = (sectionId: string) => {
539
547
  setExpandedSections((current) => ({ ...current, [sectionId]: !current[sectionId] }));
@@ -609,9 +617,9 @@ function WorkersPanel({ ctx }: { ctx: any }) {
609
617
 
610
618
  const refreshAll = useCallback(async () => {
611
619
  try {
612
- const [j, s, c, pr, pi, ph] = await Promise.all([
620
+ const [j, s, c, pr, pi, ph, cr, ext] = await Promise.all([
613
621
  get('/jobs'), get('/install/status'), get('/config'), get('/presets'),
614
- get('/providers').catch(() => null), get('/provider-health').catch(() => null),
622
+ get('/providers').catch(() => null), get('/provider-health').catch(() => null), get('/credential-references').catch(() => null), get('/extension').catch(() => null),
615
623
  ]);
616
624
  if (j.ok) setJobs(j.jobs ?? []);
617
625
  if (s.ok) setStatus(s.status);
@@ -622,6 +630,8 @@ function WorkersPanel({ ctx }: { ctx: any }) {
622
630
  ]);
623
631
  if (pi?.ok) setProviderInventory(pi);
624
632
  if (ph?.ok) setProviderHealth(ph.health ?? []);
633
+ if (cr?.ok) { setCredentialRefs(cr.records ?? []); setCredentialUnverified(cr.unverified_purges ?? []); }
634
+ if (ext?.ok) setReadinessSnapshot(ext.extension?.readiness_snapshot ?? undefined);
625
635
  } catch { /* instance restarting */ }
626
636
  }, [get]);
627
637
 
@@ -654,14 +664,40 @@ function WorkersPanel({ ctx }: { ctx: any }) {
654
664
  const refreshProviderInventory = useCallback(async () => {
655
665
  setProviderInventoryError('');
656
666
  try {
657
- const [result, health] = await Promise.all([get('/providers'), get('/provider-health').catch(() => null)]);
667
+ const [result, health, refs] = await Promise.all([get('/providers'), get('/provider-health').catch(() => null), get('/credential-references').catch(() => null)]);
658
668
  if (!result.ok) throw new Error(result.error ?? copy.providerNoInventory);
659
669
  setProviderInventory(result);
660
670
  if (health?.ok) setProviderHealth(health.health ?? []);
671
+ if (refs?.ok) { setCredentialRefs(refs.records ?? []); setCredentialUnverified(refs.unverified_purges ?? []); }
661
672
  } catch (error: any) {
662
673
  setProviderInventoryError(error?.message ?? copy.providerNoInventory);
663
674
  }
664
675
  }, [get, copy]);
676
+
677
+ const purgeCredentialRef = useCallback(async (ref: any) => {
678
+ if (ref?.ownership !== 'crew-owned' || ref?.orphan !== true || ref?.purge_capability !== 'eligible') return;
679
+ const referenceId = String(ref.reference_id ?? '');
680
+ if (!referenceId || !window.confirm(copy.credentialPurgeConfirm)) return;
681
+ setCredentialLifecycleBusy(referenceId);
682
+ setNotice(copy.working);
683
+ try {
684
+ const encoded = encodeURIComponent(referenceId);
685
+ const planned = await post(`/credential-references/${encoded}/purge-plan`, {});
686
+ if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.credentialPurgeUnavailable);
687
+ if (!window.confirm(`${copy.credentialPurgeConfirm}\n\n${referenceId}`)) return;
688
+ const result = await readJson(await fetch(withLang(`/credential-references/${encoded}`), {
689
+ method: 'DELETE', headers: { 'content-type': 'application/json' },
690
+ body: JSON.stringify({ plan_id: planned.plan.plan_id, expected_revision: planned.plan.expected_revision, confirm: true, lang: locale === 'zh' ? 'zh' : 'en' }),
691
+ }), `/credential-references/${encoded}`);
692
+ if (!result.ok || result.state !== 'VERIFIED') throw new Error(result.code ?? copy.credentialPurgeUnavailable);
693
+ setNotice(copy.saved);
694
+ await refreshProviderInventory();
695
+ } catch (error: any) {
696
+ setNotice(String(error?.message ?? error));
697
+ } finally {
698
+ setCredentialLifecycleBusy(null);
699
+ }
700
+ }, [copy, locale, post, readJson, refreshProviderInventory, withLang]);
665
701
 
666
702
  useEffect(() => {
667
703
  if (surface === CREW_UI_SURFACES.OFFICIAL) void refreshHarnessModels();
@@ -884,6 +920,38 @@ function WorkersPanel({ ctx }: { ctx: any }) {
884
920
  }
885
921
  };
886
922
 
923
+ const rollbackHarnessProvider = async (record: any, transactionId: string) => {
924
+ if (!record || !transactionId || record.desired_state !== 'absent') {
925
+ setNotice(copy.providerRollbackUnavailable);
926
+ return;
927
+ }
928
+ if (!window.confirm(copy.providerRollbackConfirm)) return;
929
+ setProviderLifecycleBusy(`rollback:${record.id}`);
930
+ setNotice(copy.working);
931
+ try {
932
+ const encoded = encodeURIComponent(record.id);
933
+ const result = await post(`/providers/${encoded}/rollback`, { transaction_id: transactionId, confirm: true });
934
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
935
+ if (result.restart_required === true && result.state === 'ROLLBACK_PENDING') {
936
+ const restartPath = 'http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart';
937
+ const restarted = await readJson(await fetch(restartPath, {
938
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }),
939
+ }), restartPath);
940
+ if (!restarted.ok) throw new Error(restarted.code ?? restarted.error ?? copy.providerLifecycleError);
941
+ const verified = await post(`/providers/${encoded}/verify-rollback`, { transaction_id: transactionId, confirm: true });
942
+ if (!verified.ok || verified.state !== 'ROLLED_BACK') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
943
+ } else if (result.state !== 'ROLLED_BACK') {
944
+ throw new Error(result.code ?? copy.providerLifecycleError);
945
+ }
946
+ setNotice(copy.saved);
947
+ await refreshProviderInventory();
948
+ } catch (error: any) {
949
+ setNotice(String(error?.message ?? error));
950
+ } finally {
951
+ setProviderLifecycleBusy(null);
952
+ }
953
+ };
954
+
887
955
  /** Card: title + one-line description header, then its fields. */
888
956
  const block = (text: { t: string; d: string }, fields: any, gridded = true) => (
889
957
  <div style={S.block}>
@@ -927,7 +995,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
927
995
  };
928
996
  const modelActivity = aggregateModelInvocations(jobs);
929
997
  const currentSurfaceResponsibilities = surfaceResponsibilities(surface);
930
- const hostReadiness = projectHostReadiness({ installStatus: status, runtime: runtimeInfo, surface });
998
+ const hostReadiness = projectHostReadiness({ installStatus: status, runtime: runtimeInfo, surface, readinessSnapshot });
931
999
 
932
1000
  if (!currentSurfaceResponsibilities.fullControlPlane) {
933
1001
  return <MinimalCrewPanel locale={locale} surface={surface} runtime={runtimeInfo} />;
@@ -1384,6 +1452,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
1384
1452
  {(providerInventory?.records ?? []).map((record: any) => {
1385
1453
  const blocked = record.ownership === 'harness' || record.desired_state === 'absent' || Number(record.references?.active_jobs ?? 0) > 0;
1386
1454
  const lifecycle = record.desired_state === 'absent' ? 'absent' : record.lifecycle?.enabled === false ? 'disabled' : record.lifecycle?.catalogued ? 'catalogued' : 'configured';
1455
+ const rollbackTransaction = (providerInventory?.lifecycle_transactions ?? []).find((entry: any) => entry.provider_id === record.id && ['VERIFIED', 'RESTART_PENDING'].includes(entry.state));
1387
1456
  return (
1388
1457
  <div key={record.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, flexWrap: 'wrap' as const }}>
1389
1458
  <span style={{ fontWeight: 600 }}>{record.display_name || record.id}</span>
@@ -1398,9 +1467,41 @@ function WorkersPanel({ ctx }: { ctx: any }) {
1398
1467
  {providerLifecycleBusy === record.id ? copy.working : copy.providerDeletePlan}
1399
1468
  </button>
1400
1469
  )}
1470
+ {record.desired_state === 'absent' && rollbackTransaction && (
1471
+ <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={providerLifecycleBusy !== null}
1472
+ onClick={() => { void rollbackHarnessProvider(record, rollbackTransaction.transaction_id); }}>
1473
+ {providerLifecycleBusy === `rollback:${record.id}` ? copy.working : copy.providerRollback}
1474
+ </button>
1475
+ )}
1401
1476
  </div>
1402
1477
  );
1403
1478
  })}
1479
+ {(credentialRefs.length > 0 || credentialUnverified.length > 0) && (
1480
+ <div style={{ marginTop: 10, borderTop: '1px solid rgba(128,128,128,0.16)', paddingTop: 8 }}>
1481
+ <div style={{ fontWeight: 600, fontSize: 12.5 }}>{copy.credentialReferences}</div>
1482
+ <div style={{ fontSize: 11, opacity: 0.6, margin: '3px 0 7px' }}>{copy.credentialReferenceHint}</div>
1483
+ {credentialUnverified.map((entry: any) => (
1484
+ <div key={`unverified:${entry.reference_id}`} style={{ color: '#c55', fontSize: 11.5 }}>
1485
+ <span style={S.mono}>{entry.reference_id}</span> · {copy.credentialPurgeUnverified}
1486
+ </div>
1487
+ ))}
1488
+ {credentialRefs.map((ref: any) => (
1489
+ <div key={ref.reference_id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11.5, flexWrap: 'wrap' as const }}>
1490
+ <span style={S.mono}>{ref.reference_id}</span>
1491
+ <span style={{ opacity: 0.65 }}>{ref.ownership}</span>
1492
+ <span style={{ color: ref.orphan ? '#c98735' : undefined }}>{ref.orphan ? copy.credentialOrphan : copy.credentialInUse(ref.consumer_count ?? 0)}</span>
1493
+ <span style={{ flex: 1 }} />
1494
+ {ref.ownership === 'crew-owned' && ref.orphan === true && ref.purge_capability === 'eligible' && (
1495
+ <button type="button" style={{ ...S.btn, padding: '2px 8px' }}
1496
+ disabled={credentialLifecycleBusy !== null}
1497
+ onClick={() => { void purgeCredentialRef(ref); }}>
1498
+ {credentialLifecycleBusy === ref.reference_id ? copy.credentialPurging : copy.credentialPurgePlan}
1499
+ </button>
1500
+ )}
1501
+ </div>
1502
+ ))}
1503
+ </div>
1504
+ )}
1404
1505
  </CollapsibleSection>
1405
1506
 
1406
1507
  <CollapsibleSection sectionId="providers" title={copy.sectionNames.providers}