@lesliechan721/aw 0.1.0-beta.10 → 0.1.0-beta.11

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 (218) hide show
  1. package/dist/authoring/assets/aw-config.d.mts +1 -50
  2. package/dist/authoring/assets/aw-config.mjs +58 -928
  3. package/dist/authoring/assets/aw-config.mjs.map +1 -1
  4. package/dist/authoring/scripts/init-plugin.mjs +28 -33
  5. package/dist/authoring/scripts/init-plugin.mjs.map +1 -1
  6. package/dist/authoring/scripts/lib/plugin-version-check.d.mts +1 -0
  7. package/dist/authoring/scripts/lib/plugin-version-check.mjs +229 -8
  8. package/dist/authoring/scripts/lib/plugin-version-check.mjs.map +1 -1
  9. package/dist/authoring/scripts/validate-plugin.mjs +165 -45
  10. package/dist/authoring/scripts/validate-plugin.mjs.map +1 -1
  11. package/dist/scripts/bundle-plugins.js +18 -4
  12. package/dist/scripts/bundle-plugins.js.map +1 -1
  13. package/dist/scripts/test-plugin-scripts.d.ts +2 -0
  14. package/dist/scripts/test-plugin-scripts.js +21 -5
  15. package/dist/scripts/test-plugin-scripts.js.map +1 -1
  16. package/dist/src/commands/aw-capabilities.d.ts +1 -1
  17. package/dist/src/commands/aw-capabilities.js +1 -1
  18. package/dist/src/commands/aw-capabilities.js.map +1 -1
  19. package/dist/src/commands/catalog-dev-source.d.ts +6 -3
  20. package/dist/src/commands/catalog-dev-source.js +96 -35
  21. package/dist/src/commands/catalog-dev-source.js.map +1 -1
  22. package/dist/src/commands/catalog-publication-inspection.d.ts +1 -1
  23. package/dist/src/commands/catalog-publication-inspection.js +54 -8
  24. package/dist/src/commands/catalog-publication-inspection.js.map +1 -1
  25. package/dist/src/commands/catalog-publication-journal.js +1 -1
  26. package/dist/src/commands/catalog-publication-journal.js.map +1 -1
  27. package/dist/src/commands/catalog-source.d.ts +19 -4
  28. package/dist/src/commands/catalog-source.js +121 -12
  29. package/dist/src/commands/catalog-source.js.map +1 -1
  30. package/dist/src/commands/catalog-store.d.ts +26 -7
  31. package/dist/src/commands/catalog-store.js +361 -42
  32. package/dist/src/commands/catalog-store.js.map +1 -1
  33. package/dist/src/commands/console.js +28 -17
  34. package/dist/src/commands/console.js.map +1 -1
  35. package/dist/src/commands/operation-plan.d.ts +2 -1
  36. package/dist/src/commands/operation-plan.js +24 -0
  37. package/dist/src/commands/operation-plan.js.map +1 -1
  38. package/dist/src/commands/operation-planner.d.ts +2 -2
  39. package/dist/src/commands/operation-planner.js +11 -19
  40. package/dist/src/commands/operation-planner.js.map +1 -1
  41. package/dist/src/commands/options.d.ts +2 -2
  42. package/dist/src/commands/options.js +18 -4
  43. package/dist/src/commands/options.js.map +1 -1
  44. package/dist/src/commands/plugin-capabilities.js +2 -2
  45. package/dist/src/commands/plugin-capabilities.js.map +1 -1
  46. package/dist/src/commands/rebuild-command.d.ts +7 -0
  47. package/dist/src/commands/rebuild-command.js +769 -279
  48. package/dist/src/commands/rebuild-command.js.map +1 -1
  49. package/dist/src/commands/rebuild-integration.d.ts +24 -47
  50. package/dist/src/commands/rebuild-integration.js +1074 -1011
  51. package/dist/src/commands/rebuild-integration.js.map +1 -1
  52. package/dist/src/commands/rebuild-operations.d.ts +50 -12
  53. package/dist/src/commands/rebuild-operations.js +207 -101
  54. package/dist/src/commands/rebuild-operations.js.map +1 -1
  55. package/dist/src/commands/status-v6.d.ts +21 -2
  56. package/dist/src/commands/status-v6.js +163 -31
  57. package/dist/src/commands/status-v6.js.map +1 -1
  58. package/dist/src/commands/sync-command.d.ts +17 -0
  59. package/dist/src/commands/sync-command.js +45 -37
  60. package/dist/src/commands/sync-command.js.map +1 -1
  61. package/dist/src/commands/write-commands.d.ts +5 -0
  62. package/dist/src/commands/write-commands.js +50 -3
  63. package/dist/src/commands/write-commands.js.map +1 -1
  64. package/dist/src/generators/local-plugin.js +14 -4
  65. package/dist/src/generators/local-plugin.js.map +1 -1
  66. package/dist/src/index.js +216 -85
  67. package/dist/src/index.js.map +1 -1
  68. package/dist/src/output/command-receipt.js +1 -3
  69. package/dist/src/output/command-receipt.js.map +1 -1
  70. package/dist/src/output/rebuild-progress.js +1 -3
  71. package/dist/src/output/rebuild-progress.js.map +1 -1
  72. package/dist/src/portable/materializer.js +6 -6
  73. package/dist/src/portable/materializer.js.map +1 -1
  74. package/dist/src/tui/catalog-generation.js +41 -1
  75. package/dist/src/tui/catalog-generation.js.map +1 -1
  76. package/dist/src/tui/catalog-sources/session.d.ts +1 -0
  77. package/dist/src/tui/catalog-sources/session.js +16 -3
  78. package/dist/src/tui/catalog-sources/session.js.map +1 -1
  79. package/dist/src/tui/catalog-sources/types.d.ts +3 -2
  80. package/dist/src/tui/components.d.ts +4 -0
  81. package/dist/src/tui/components.js +36 -24
  82. package/dist/src/tui/components.js.map +1 -1
  83. package/dist/src/tui/console-app-v4.d.ts +13 -4
  84. package/dist/src/tui/console-app-v4.js +2067 -839
  85. package/dist/src/tui/console-app-v4.js.map +1 -1
  86. package/dist/src/tui/controller-runtime.js +585 -33
  87. package/dist/src/tui/controller-runtime.js.map +1 -1
  88. package/dist/src/tui/controller.d.ts +152 -6
  89. package/dist/src/tui/controller.js +1168 -156
  90. package/dist/src/tui/controller.js.map +1 -1
  91. package/dist/src/tui/diagnostic-projection.d.ts +22 -0
  92. package/dist/src/tui/diagnostic-projection.js +437 -0
  93. package/dist/src/tui/diagnostic-projection.js.map +1 -0
  94. package/dist/src/tui/diagnostics.d.ts +1 -1
  95. package/dist/src/tui/diagnostics.js +6 -1
  96. package/dist/src/tui/diagnostics.js.map +1 -1
  97. package/dist/src/tui/glossary.d.ts +23 -0
  98. package/dist/src/tui/glossary.js +42 -0
  99. package/dist/src/tui/glossary.js.map +1 -0
  100. package/dist/src/tui/index.d.ts +4 -1
  101. package/dist/src/tui/index.js +4 -1
  102. package/dist/src/tui/index.js.map +1 -1
  103. package/dist/src/tui/keys.d.ts +44 -0
  104. package/dist/src/tui/keys.js +43 -15
  105. package/dist/src/tui/keys.js.map +1 -1
  106. package/dist/src/tui/lifecycle-action-view.d.ts +6 -0
  107. package/dist/src/tui/lifecycle-action-view.js +34 -0
  108. package/dist/src/tui/lifecycle-action-view.js.map +1 -0
  109. package/dist/src/tui/model.d.ts +12 -2
  110. package/dist/src/tui/model.js +128 -52
  111. package/dist/src/tui/model.js.map +1 -1
  112. package/dist/src/tui/operation-diagnostic-screens.d.ts +21 -0
  113. package/dist/src/tui/operation-diagnostic-screens.js +101 -0
  114. package/dist/src/tui/operation-diagnostic-screens.js.map +1 -0
  115. package/dist/src/tui/operation-history.d.ts +80 -0
  116. package/dist/src/tui/operation-history.js +130 -0
  117. package/dist/src/tui/operation-history.js.map +1 -0
  118. package/dist/src/tui/operation-review-planner.js +306 -86
  119. package/dist/src/tui/operation-review-planner.js.map +1 -1
  120. package/dist/src/tui/router.d.ts +82 -17
  121. package/dist/src/tui/router.js +186 -38
  122. package/dist/src/tui/router.js.map +1 -1
  123. package/dist/src/tui/screen-registry.d.ts +546 -0
  124. package/dist/src/tui/screen-registry.js +179 -0
  125. package/dist/src/tui/screen-registry.js.map +1 -0
  126. package/dist/src/tui/session-console-app.d.ts +6 -0
  127. package/dist/src/tui/session-console-app.js +13 -0
  128. package/dist/src/tui/session-console-app.js.map +1 -0
  129. package/dist/src/tui/session-host.d.ts +44 -0
  130. package/dist/src/tui/session-host.js +197 -0
  131. package/dist/src/tui/session-host.js.map +1 -0
  132. package/dist/src/types.d.ts +64 -3
  133. package/dist/src/types.js +1 -1
  134. package/dist/src/types.js.map +1 -1
  135. package/dist/src/utils/agent-version.js +1 -1
  136. package/dist/src/utils/agent-version.js.map +1 -1
  137. package/dist/src/utils/artifact-renderer.d.ts +2 -1
  138. package/dist/src/utils/artifact-renderer.js +52 -52
  139. package/dist/src/utils/artifact-renderer.js.map +1 -1
  140. package/dist/src/utils/bundle-layout.js +4 -4
  141. package/dist/src/utils/bundle-layout.js.map +1 -1
  142. package/dist/src/utils/catalog-capability-projection.d.ts +8 -0
  143. package/dist/src/utils/catalog-capability-projection.js +14 -0
  144. package/dist/src/utils/catalog-capability-projection.js.map +1 -0
  145. package/dist/src/utils/catalog-generation-resolver.d.ts +14 -2
  146. package/dist/src/utils/catalog-generation-resolver.js +49 -27
  147. package/dist/src/utils/catalog-generation-resolver.js.map +1 -1
  148. package/dist/src/utils/catalog-generation-service.d.ts +8 -1
  149. package/dist/src/utils/catalog-generation-service.js +81 -47
  150. package/dist/src/utils/catalog-generation-service.js.map +1 -1
  151. package/dist/src/utils/catalog-generation-store.d.ts +1 -0
  152. package/dist/src/utils/catalog-generation-store.js +60 -5
  153. package/dist/src/utils/catalog-generation-store.js.map +1 -1
  154. package/dist/src/utils/catalog-operation-context.js +1 -0
  155. package/dist/src/utils/catalog-operation-context.js.map +1 -1
  156. package/dist/src/utils/catalog-public-error.js +4 -0
  157. package/dist/src/utils/catalog-public-error.js.map +1 -1
  158. package/dist/src/utils/catalog-source-impact.d.ts +3 -2
  159. package/dist/src/utils/catalog-source-impact.js +142 -20
  160. package/dist/src/utils/catalog-source-impact.js.map +1 -1
  161. package/dist/src/utils/catalog-source-references.js +95 -9
  162. package/dist/src/utils/catalog-source-references.js.map +1 -1
  163. package/dist/src/utils/catalog-sources.d.ts +1 -1
  164. package/dist/src/utils/catalog-sources.js +19 -3
  165. package/dist/src/utils/catalog-sources.js.map +1 -1
  166. package/dist/src/utils/codex-manager.js +8 -0
  167. package/dist/src/utils/codex-manager.js.map +1 -1
  168. package/dist/src/utils/codex-native-observation.d.ts +34 -1
  169. package/dist/src/utils/codex-native-observation.js +132 -0
  170. package/dist/src/utils/codex-native-observation.js.map +1 -1
  171. package/dist/src/utils/codex-reconciler.d.ts +0 -2
  172. package/dist/src/utils/codex-reconciler.js.map +1 -1
  173. package/dist/src/utils/codex-shared-native.d.ts +2 -26
  174. package/dist/src/utils/codex-shared-native.js +8 -82
  175. package/dist/src/utils/codex-shared-native.js.map +1 -1
  176. package/dist/src/utils/git-catalog-provider.d.ts +3 -0
  177. package/dist/src/utils/git-catalog-provider.js +40 -1
  178. package/dist/src/utils/git-catalog-provider.js.map +1 -1
  179. package/dist/src/utils/local-marketplace.d.ts +1 -0
  180. package/dist/src/utils/local-marketplace.js +69 -73
  181. package/dist/src/utils/local-marketplace.js.map +1 -1
  182. package/dist/src/utils/multi-source-catalog.js +1 -0
  183. package/dist/src/utils/multi-source-catalog.js.map +1 -1
  184. package/dist/src/utils/native-cli.d.ts +9 -4
  185. package/dist/src/utils/native-cli.js +280 -34
  186. package/dist/src/utils/native-cli.js.map +1 -1
  187. package/dist/src/utils/path-catalog-provider.js +2 -0
  188. package/dist/src/utils/path-catalog-provider.js.map +1 -1
  189. package/dist/src/utils/plugin-config-write.d.ts +15 -13
  190. package/dist/src/utils/plugin-config-write.js +418 -198
  191. package/dist/src/utils/plugin-config-write.js.map +1 -1
  192. package/dist/src/utils/plugin-config.d.ts +48 -4
  193. package/dist/src/utils/plugin-config.js +289 -38
  194. package/dist/src/utils/plugin-config.js.map +1 -1
  195. package/dist/src/utils/plugin-scripts.d.ts +20 -12
  196. package/dist/src/utils/plugin-scripts.js +376 -109
  197. package/dist/src/utils/plugin-scripts.js.map +1 -1
  198. package/dist/src/utils/process-tree.d.ts +2 -2
  199. package/dist/src/utils/process-tree.js +1 -1
  200. package/dist/src/utils/process-tree.js.map +1 -1
  201. package/dist/src/utils/project-boundary.d.ts +11 -1
  202. package/dist/src/utils/project-boundary.js +17 -8
  203. package/dist/src/utils/project-boundary.js.map +1 -1
  204. package/dist/src/utils/safe-operation-diagnostic.d.ts +15 -0
  205. package/dist/src/utils/safe-operation-diagnostic.js +33 -0
  206. package/dist/src/utils/safe-operation-diagnostic.js.map +1 -0
  207. package/dist/src/utils/sensitive-content.d.ts +1 -0
  208. package/dist/src/utils/sensitive-content.js +15 -0
  209. package/dist/src/utils/sensitive-content.js.map +1 -0
  210. package/dist/src/utils/windows-claude-shim.d.ts +9 -0
  211. package/dist/src/utils/windows-claude-shim.js +141 -0
  212. package/dist/src/utils/windows-claude-shim.js.map +1 -0
  213. package/dist/vitest.config.js +91 -10
  214. package/dist/vitest.config.js.map +1 -1
  215. package/package.json +9 -9
  216. package/dist/authoring/templates/aw-config-runtime.d.mts +0 -2
  217. package/dist/authoring/templates/aw-config-runtime.mjs +0 -407
  218. package/dist/authoring/templates/aw-config-runtime.mjs.map +0 -1
@@ -1,14 +1,25 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { CatalogGenerationError } from '../utils/catalog-generation-error.js';
3
+ import { readSafeOperationError } from '../utils/safe-operation-diagnostic.js';
3
4
  import { buildRebuildReceiptJson, requestedIntentAction } from '../output/command-receipt.js';
4
5
  import { buildOperationResultModel } from '../commands/operation-planner.js';
5
6
  import { buildOperationReviewProjection } from './operation-review-planner.js';
6
7
  import { buildTuiViewModel, dependencyPlacementTargets, hasInstalledPlacement, } from './model.js';
7
- import { safeDiagnosticCode } from './diagnostics.js';
8
+ import { safeDiagnosticCode, sanitizeDiagnosticText } from './diagnostics.js';
9
+ import { projectExecutionDiagnostics, projectThrownOperationDiagnostic } from './diagnostic-projection.js';
10
+ import { OperationHistoryStore, } from './operation-history.js';
8
11
  import { targetHasArtifactCollision } from '../utils/target-eligibility.js';
9
12
  import { TuiRouter, } from './router.js';
13
+ import { normalizeTuiRoute } from './screen-registry.js';
10
14
  import { CatalogSourcesSession } from './catalog-sources/session.js';
11
15
  import { catalogSourceAutoCheckOnlyValue, projectCatalogSourceMutationWarnings, } from './catalog-sources/review.js';
16
+ export const PLUGIN_WRITE_BATCH_ACTIONS = [
17
+ 'install',
18
+ 'sync',
19
+ 'reinstall',
20
+ 'uninstall',
21
+ 'cleanup',
22
+ ];
12
23
  function reviewPreparationStageLabel(stage) {
13
24
  return {
14
25
  refresh: '刷新相关来源',
@@ -48,6 +59,176 @@ function stableValue(value) {
48
59
  .sort(([left], [right]) => left.localeCompare(right, 'en'))
49
60
  .map(([key, entry]) => [key, stableValue(entry)]));
50
61
  }
62
+ function canonicalPluginIds(pluginIds) {
63
+ return [...new Set(pluginIds)].sort((left, right) => left.localeCompare(right, 'en'));
64
+ }
65
+ function canonicalBatchTargets(targets) {
66
+ const byKey = new Map();
67
+ for (const target of targets) {
68
+ byKey.set([target.pluginId, target.platform, target.scope].join('\0'), target);
69
+ }
70
+ return [...byKey.values()].sort((left, right) => [left.pluginId, left.platform, left.scope].join('\0')
71
+ .localeCompare([right.pluginId, right.platform, right.scope].join('\0'), 'en'));
72
+ }
73
+ function normalizeOperationIntent(intent) {
74
+ if (intent.kind !== 'pluginWriteBatch')
75
+ return intent;
76
+ if (intent.selection.kind === 'targets') {
77
+ return {
78
+ ...intent,
79
+ selection: { kind: 'targets', targets: canonicalBatchTargets(intent.selection.targets) },
80
+ };
81
+ }
82
+ if (intent.selection.kind !== 'selected')
83
+ return intent;
84
+ return {
85
+ ...intent,
86
+ selection: {
87
+ kind: 'selected',
88
+ pluginIds: canonicalPluginIds(intent.selection.pluginIds),
89
+ },
90
+ };
91
+ }
92
+ function assertPluginWriteBatchEvidence(evidence) {
93
+ const candidate = evidence;
94
+ if (!Array.isArray(candidate.requestedPluginIds) || !Array.isArray(candidate.groups)) {
95
+ throw new TuiOperationBlockedError('batchEvidenceInvalid', '批量确认信息不完整。');
96
+ }
97
+ const requestedPluginIds = canonicalPluginIds(candidate.requestedPluginIds);
98
+ if (requestedPluginIds.length === 0
99
+ || JSON.stringify(requestedPluginIds) !== JSON.stringify(candidate.requestedPluginIds)) {
100
+ throw new TuiOperationBlockedError('batchEvidenceInvalid', '批量根插件集合无效。');
101
+ }
102
+ const requested = new Set(requestedPluginIds);
103
+ const groupIds = new Set();
104
+ const groupedRoots = new Set();
105
+ for (const group of candidate.groups) {
106
+ if (!group || typeof group.groupId !== 'string' || group.groupId.length === 0
107
+ || typeof group.sourceId !== 'string' || group.sourceId.length === 0
108
+ || groupIds.has(group.groupId)
109
+ || !Array.isArray(group.requestedPluginIds)
110
+ || group.requestedPluginIds.length === 0
111
+ || JSON.stringify(canonicalPluginIds(group.requestedPluginIds))
112
+ !== JSON.stringify(group.requestedPluginIds)
113
+ || group.requestedPluginIds.some((pluginId) => !requested.has(pluginId))
114
+ || group.executable !== Boolean(group.preview?.operationPlan)) {
115
+ throw new TuiOperationBlockedError('batchEvidenceInvalid', '批量分组确认信息无效。');
116
+ }
117
+ groupIds.add(group.groupId);
118
+ group.requestedPluginIds.forEach((pluginId) => groupedRoots.add(pluginId));
119
+ }
120
+ if (requestedPluginIds.some((pluginId) => !groupedRoots.has(pluginId))) {
121
+ throw new TuiOperationBlockedError('batchEvidenceInvalid', '批量分组缺少请求根插件。');
122
+ }
123
+ }
124
+ export function pluginWriteBatchFailedRequestedTargets(execution) {
125
+ const failed = [];
126
+ for (const group of execution.groupResults) {
127
+ const roots = new Set(group.requestedPluginIds);
128
+ const targetsByPlugin = new Map();
129
+ for (const target of group.result.targetResults) {
130
+ const pluginId = target.target.pluginId;
131
+ if (!pluginId)
132
+ continue;
133
+ const entries = targetsByPlugin.get(pluginId) ?? [];
134
+ entries.push(target);
135
+ targetsByPlugin.set(pluginId, entries);
136
+ }
137
+ const successfulRootKeys = new Set(group.result.targetResults.flatMap((target) => {
138
+ const pluginId = target.target.pluginId;
139
+ return pluginId && roots.has(pluginId)
140
+ && target.state !== 'failed' && target.action !== 'skipped'
141
+ ? [[pluginId, target.target.platform, target.target.scope].join('\0')]
142
+ : [];
143
+ }));
144
+ const unsuccessful = group.result.targetResults.filter((target) => target.state === 'failed' || target.action === 'skipped');
145
+ for (const target of unsuccessful) {
146
+ const pending = target.target.pluginId
147
+ ? [{ pluginId: target.target.pluginId, platform: target.target.platform }]
148
+ : [];
149
+ const visited = new Set();
150
+ while (pending.length > 0) {
151
+ const current = pending.shift();
152
+ const visitKey = [current.pluginId, current.platform].join('\0');
153
+ if (visited.has(visitKey))
154
+ continue;
155
+ visited.add(visitKey);
156
+ const candidates = (targetsByPlugin.get(current.pluginId) ?? [])
157
+ .filter((candidate) => candidate.target.platform === current.platform);
158
+ if (roots.has(current.pluginId)) {
159
+ if (candidates.length === 0) {
160
+ const retryTarget = {
161
+ pluginId: current.pluginId,
162
+ platform: current.platform,
163
+ scope: group.scope,
164
+ };
165
+ const key = [retryTarget.pluginId, retryTarget.platform, retryTarget.scope].join('\0');
166
+ if (!successfulRootKeys.has(key))
167
+ failed.push(retryTarget);
168
+ }
169
+ for (const candidate of candidates) {
170
+ const retryTarget = {
171
+ pluginId: current.pluginId,
172
+ platform: candidate.target.platform,
173
+ scope: candidate.target.scope,
174
+ };
175
+ const key = [current.pluginId, retryTarget.platform, retryTarget.scope].join('\0');
176
+ if (!successfulRootKeys.has(key))
177
+ failed.push(retryTarget);
178
+ }
179
+ continue;
180
+ }
181
+ for (const candidate of candidates) {
182
+ pending.push(...(candidate.requiredBy ?? []).map((pluginId) => ({
183
+ pluginId, platform: current.platform,
184
+ })));
185
+ }
186
+ }
187
+ }
188
+ }
189
+ return canonicalBatchTargets(failed);
190
+ }
191
+ export function pluginWriteBatchFailedRequestedPluginIds(execution) {
192
+ return canonicalPluginIds(pluginWriteBatchFailedRequestedTargets(execution)
193
+ .map((target) => target.pluginId));
194
+ }
195
+ function collectOperationSensitiveValues(intent) {
196
+ if (intent.kind !== 'configSave')
197
+ return [];
198
+ const sensitiveKeys = new Set([...(intent.sensitiveKeys ?? []), ...(intent.privateKeys ?? [])]);
199
+ const values = [];
200
+ const visit = (value) => {
201
+ if (typeof value === 'string')
202
+ values.push(value);
203
+ else if (Array.isArray(value))
204
+ value.forEach(visit);
205
+ else if (value && typeof value === 'object')
206
+ Object.values(value).forEach(visit);
207
+ };
208
+ for (const [key, value] of Object.entries(intent.updates ?? {})) {
209
+ if (sensitiveKeys.has(key))
210
+ visit(value);
211
+ }
212
+ return values;
213
+ }
214
+ function sanitizeOperationSummary(summary, sensitiveValues) {
215
+ const text = (value) => sanitizeDiagnosticText(value, [], sensitiveValues);
216
+ return {
217
+ ...summary,
218
+ message: text(summary.message),
219
+ ...(summary.messages ? { messages: summary.messages.map(text) } : {}),
220
+ ...(summary.changes ? { changes: summary.changes.map(text) } : {}),
221
+ ...(summary.warnings ? { warnings: summary.warnings.map(text) } : {}),
222
+ ...(summary.recoveryActions ? { recoveryActions: summary.recoveryActions.map(text) } : {}),
223
+ ...(summary.quarantineBasename ? { quarantineBasename: text(summary.quarantineBasename) } : {}),
224
+ ...(summary.portable ? {
225
+ portable: {
226
+ ...summary.portable,
227
+ ...(summary.portable.recovery ? { recovery: text(summary.portable.recovery) } : {}),
228
+ },
229
+ } : {}),
230
+ };
231
+ }
51
232
  function deepFreeze(value) {
52
233
  if (typeof value !== 'object' || value === null || Object.isFrozen(value))
53
234
  return value;
@@ -58,27 +239,33 @@ function deepFreeze(value) {
58
239
  function cloneFrozen(value) {
59
240
  return deepFreeze(structuredClone(value));
60
241
  }
61
- function publicReviewPreparation(snapshot) {
62
- if (snapshot.intent.kind !== 'catalogGeneration' || !snapshot.intent.sourceMutation)
63
- return snapshot;
64
- const mutation = snapshot.intent.sourceMutation;
65
- const sourceMutation = mutation.action === 'add'
66
- ? {
67
- ...mutation,
68
- source: mutation.source.type === 'path'
69
- ? { ...mutation.source, path: '<private>' }
70
- : { ...mutation.source, url: '<private>' },
71
- }
72
- : {
73
- ...mutation,
74
- update: 'url' in mutation.update
75
- ? { ...mutation.update, ...(mutation.update.url ? { url: '<private>' } : {}) }
76
- : { ...mutation.update, ...('path' in mutation.update && mutation.update.path
77
- ? { path: '<private>' } : {}) },
78
- };
79
- return { ...snapshot, intent: { ...snapshot.intent, sourceMutation } };
80
- }
81
242
  export function operationEvidenceFingerprint(evidence) {
243
+ if ('groups' in evidence && 'requestedPluginIds' in evidence) {
244
+ const batch = evidence;
245
+ return crypto.createHash('sha256')
246
+ .update(JSON.stringify(stableValue({
247
+ requestedPluginIds: batch.requestedPluginIds,
248
+ groups: batch.groups.map((group) => ({
249
+ groupId: group.groupId,
250
+ sourceId: group.sourceId,
251
+ platform: group.platform,
252
+ requestedPluginIds: group.requestedPluginIds,
253
+ executable: group.executable,
254
+ planFingerprint: group.preview.operationPlan?.planFingerprint,
255
+ overallState: group.preview.overallState,
256
+ targetResults: group.preview.targetResults.map((target) => ({
257
+ pluginId: target.target.pluginId,
258
+ platform: target.target.platform,
259
+ scope: target.target.scope,
260
+ state: target.state,
261
+ action: target.action,
262
+ errorCode: target.errorCode,
263
+ requiredBy: target.requiredBy,
264
+ })),
265
+ })),
266
+ })))
267
+ .digest('hex');
268
+ }
82
269
  if (evidence.operationPlan)
83
270
  return evidence.operationPlan.planFingerprint;
84
271
  if (evidence.resetTarget) {
@@ -90,8 +277,14 @@ export function operationEvidenceFingerprint(evidence) {
90
277
  .digest('hex');
91
278
  }
92
279
  const { codexManager: _runtimeSelection, ...staleEvidence } = evidence;
280
+ const config = staleEvidence.config
281
+ ? (({ shareability: _shareability, ...identity }) => identity)(staleEvidence.config)
282
+ : undefined;
93
283
  return crypto.createHash('sha256')
94
- .update(JSON.stringify(stableValue(staleEvidence)))
284
+ .update(JSON.stringify(stableValue({
285
+ ...staleEvidence,
286
+ ...(config ? { config } : {}),
287
+ })))
95
288
  .digest('hex');
96
289
  }
97
290
  export function operationEvidenceMatches(expected, current) {
@@ -125,6 +318,9 @@ export function userIssueKey(issue) {
125
318
  .join('\0');
126
319
  }
127
320
  function issueCode(error) {
321
+ const safeOperationError = readSafeOperationError(error);
322
+ if (safeOperationError)
323
+ return safeDiagnosticCode(safeOperationError.code, 'operationBlocked');
128
324
  if (error instanceof TuiOperationBlockedError)
129
325
  return error.code;
130
326
  if (error instanceof CatalogGenerationError)
@@ -160,12 +356,15 @@ export class TuiController {
160
356
  #listeners = new Set();
161
357
  #health = new Map();
162
358
  #tickets = new Map();
359
+ #reviewPreparationIntents = new Map();
163
360
  #inflightOperations = new Map();
361
+ #pendingReads = new Set();
164
362
  #abort = new AbortController();
165
363
  #progressUnsubscribe;
166
364
  #progressFeed;
167
365
  #phase = 'idle';
168
366
  #status;
367
+ #statusReadAtMs;
169
368
  #catalogGenerations = new Map();
170
369
  #catalogFreshness = new Map();
171
370
  #catalogSourcesSession;
@@ -177,18 +376,28 @@ export class TuiController {
177
376
  #reviewPreparation;
178
377
  #reviewPreparationToken = 0;
179
378
  #result;
379
+ #resultAnchor;
380
+ #portableResultSkills;
180
381
  #loadError;
181
382
  #portableDraft;
383
+ #recoverablePortableDraft;
182
384
  #portableRepreparePluginId;
183
385
  #portableRequestToken = 0;
184
386
  #portableRequestAbort;
185
387
  #portablePending;
186
388
  #installPlacementDraft;
389
+ #recoverableInstallPlacementDraft;
187
390
  #installPlacementRequestToken = 0;
188
391
  #installPlacementRequestAbort;
189
392
  #generation = 0;
190
393
  #disposed = false;
191
- #externalDraft;
394
+ #retiring = false;
395
+ #externalDrafts = [];
396
+ #batchRetryIntent;
397
+ #operationHistory = new OperationHistoryStore();
398
+ #sessionEpoch = 0;
399
+ #lastRecordedResultId;
400
+ #operationDiagnostics = new Map();
192
401
  constructor(dependencies, progressFeed) {
193
402
  this.dependencies = dependencies;
194
403
  this.#progressFeed = progressFeed;
@@ -211,6 +420,8 @@ export class TuiController {
211
420
  });
212
421
  if (progressFeed) {
213
422
  this.#progressUnsubscribe = progressFeed.subscribe((progress) => {
423
+ if (this.#router.snapshot.current.route.name === 'result')
424
+ return;
214
425
  this.#progress = {
215
426
  ...structuredClone(progress),
216
427
  messages: progress.messages.slice(-8),
@@ -239,6 +450,7 @@ export class TuiController {
239
450
  get snapshot() {
240
451
  return cloneFrozen({
241
452
  phase: this.#phase,
453
+ ...(this.#statusReadAtMs !== undefined ? { statusReadAtMs: this.#statusReadAtMs } : {}),
242
454
  ...(this.#status?.cliChannel ? { cliChannel: this.#status.cliChannel } : {}),
243
455
  router: this.#router.snapshot,
244
456
  ...(this.#viewModel ? { viewModel: this.#viewModel } : {}),
@@ -252,9 +464,11 @@ export class TuiController {
252
464
  progressTargets: this.#progressTargets,
253
465
  ...(this.#activeReview ? { activeReview: this.#activeReview } : {}),
254
466
  ...(this.#reviewPreparation
255
- ? { reviewPreparation: publicReviewPreparation(this.#reviewPreparation.snapshot) }
467
+ ? { reviewPreparation: this.#reviewPreparation.snapshot }
256
468
  : {}),
257
469
  ...(this.#result ? { result: this.#result } : {}),
470
+ operationHistorySummaries: this.#operationHistory.list(),
471
+ ...(this.#portableResultSkills ? { portableResultSkills: this.#portableResultSkills } : {}),
258
472
  ...(this.#loadError ? { loadError: this.#loadError } : {}),
259
473
  ...(this.#portableDraft ? { portableDraft: this.#portableDraft } : {}),
260
474
  ...(this.#installPlacementDraft
@@ -278,13 +492,222 @@ export class TuiController {
278
492
  listener(this.snapshot);
279
493
  return () => this.#listeners.delete(listener);
280
494
  }
495
+ get hasActiveWrite() {
496
+ return this.#inflightOperations.size > 0;
497
+ }
498
+ get retiring() {
499
+ return this.#retiring;
500
+ }
501
+ get hasSessionDraft() {
502
+ return this.#externalDrafts.length > 0
503
+ || this.#portableDraft !== undefined
504
+ || this.#installPlacementDraft !== undefined;
505
+ }
506
+ get externalDrafts() {
507
+ return structuredClone(this.#externalDrafts);
508
+ }
509
+ attachOperationHistory(history, epoch) {
510
+ this.#operationHistory = history;
511
+ this.#sessionEpoch = epoch;
512
+ }
513
+ listOperationHistory() {
514
+ return this.#operationHistory.list();
515
+ }
516
+ getOperationDiagnostic(recordId) {
517
+ return this.#operationHistory.get(recordId);
518
+ }
519
+ previewOperationReport(recordId) {
520
+ return this.#operationHistory.preview(recordId);
521
+ }
522
+ saveOperationReport(recordId, filePath) {
523
+ return this.#operationHistory.save(recordId, filePath);
524
+ }
525
+ /** @internal Test-only observability for bounded private diagnostic ownership. */
526
+ operationDiagnosticRetentionCountForTests() {
527
+ return this.#operationDiagnostics.size;
528
+ }
529
+ recordUiFailure(error, context) {
530
+ const recordId = this.#recordStandaloneFailure(context.code, context.stage, error);
531
+ this.#publish();
532
+ return recordId;
533
+ }
534
+ #recordStandaloneFailure(code, stage, error) {
535
+ const operationId = `ui-${crypto.randomUUID()}`;
536
+ const recordId = `${this.#sessionEpoch}:${operationId}`;
537
+ const diagnostic = projectThrownOperationDiagnostic({
538
+ error,
539
+ id: `${recordId}:failure`,
540
+ stage: safeDiagnosticCode(stage, 'ui'),
541
+ fallbackCode: safeDiagnosticCode(code, 'uiOperationFailed'),
542
+ });
543
+ this.#operationHistory.record({
544
+ summary: {
545
+ recordId,
546
+ epoch: this.#sessionEpoch,
547
+ operationId,
548
+ createdAtMs: this.dependencies.now?.() ?? Date.now(),
549
+ state: 'blocked',
550
+ code: diagnostic.code,
551
+ message: diagnostic.safeMessage,
552
+ diagnosticCount: 1,
553
+ effects: {
554
+ configCommit: 'notApplicable', awWrite: 'notStarted',
555
+ preApplyExternalEffect: 'notApplicable', finalVerification: 'notStarted',
556
+ },
557
+ },
558
+ diagnostics: [diagnostic],
559
+ targets: [],
560
+ });
561
+ return recordId;
562
+ }
563
+ #trackRead(promise) {
564
+ this.#pendingReads.add(promise);
565
+ void promise.finally(() => this.#pendingReads.delete(promise)).catch(() => undefined);
566
+ return promise;
567
+ }
568
+ beginRetirement() {
569
+ this.#retiring = true;
570
+ }
571
+ async waitForWrites() {
572
+ this.beginRetirement();
573
+ while (this.#inflightOperations.size > 0) {
574
+ await Promise.allSettled([...this.#inflightOperations.values()].map((operation) => operation.promise));
575
+ }
576
+ }
577
+ async cancelAndWaitForReads() {
578
+ if (this.hasActiveWrite)
579
+ return false;
580
+ this.beginRetirement();
581
+ this.#generation += 1;
582
+ this.#cancelReviewPreparation();
583
+ this.#cancelPortableRequest();
584
+ this.#cancelInstallPlacementRequest();
585
+ this.#abort.abort();
586
+ const catalogDrain = this.#catalogSourcesSession.cancelAndWait();
587
+ while (this.#pendingReads.size > 0) {
588
+ await Promise.allSettled([...this.#pendingReads]);
589
+ }
590
+ await catalogDrain;
591
+ return !this.hasActiveWrite;
592
+ }
593
+ #assertAcceptingActions() {
594
+ if (this.#retiring || this.#disposed) {
595
+ throw new DOMException('Controller session is retiring.', 'AbortError');
596
+ }
597
+ }
598
+ goHome(options = {}) {
599
+ if (this.hasSessionDraft && !options.discardDrafts)
600
+ return false;
601
+ this.#cancelReviewPreparation();
602
+ this.#cancelPortableRequest();
603
+ this.#cancelInstallPlacementRequest();
604
+ this.#tickets.clear();
605
+ this.#activeReview = undefined;
606
+ this.#portableDraft = undefined;
607
+ this.#recoverablePortableDraft = undefined;
608
+ this.#installPlacementDraft = undefined;
609
+ this.#recoverableInstallPlacementDraft = undefined;
610
+ this.#batchRetryIntent = undefined;
611
+ this.#externalDrafts = [];
612
+ this.#result = undefined;
613
+ this.#resultAnchor = undefined;
614
+ this.#portableResultSkills = undefined;
615
+ this.#progress = { messages: [] };
616
+ this.#progressTargets = [];
617
+ this.#router.resetToRoot();
618
+ this.#publish();
619
+ return true;
620
+ }
281
621
  #publish() {
282
622
  if (this.#disposed)
283
623
  return;
624
+ this.#recordTerminalResult();
284
625
  const snapshot = this.snapshot;
285
626
  for (const listener of this.#listeners)
286
627
  listener(snapshot);
287
628
  }
629
+ #recordTerminalResult() {
630
+ const result = this.#result;
631
+ if (!result || result.state === 'stale')
632
+ return;
633
+ const recordId = `${this.#sessionEpoch}:${result.operationId}`;
634
+ if (this.#lastRecordedResultId === recordId)
635
+ return;
636
+ const projected = this.#operationDiagnostics.get(result.operationId) ?? {
637
+ diagnostics: [],
638
+ effects: {
639
+ configCommit: 'notApplicable',
640
+ awWrite: result.state === 'succeeded' ? 'completed' : 'unknown',
641
+ preApplyExternalEffect: 'notApplicable',
642
+ finalVerification: result.state === 'succeeded' ? 'completed' : 'unknown',
643
+ },
644
+ };
645
+ if ('summary' in result && result.summary) {
646
+ Object.assign(result.summary, sanitizeOperationSummary(result.summary, projected.sensitiveValues ?? []));
647
+ }
648
+ const diagnosticIds = projected.diagnostics.map((diagnostic) => diagnostic.id);
649
+ Object.assign(result, { operationRecordId: recordId, diagnosticIds, effects: projected.effects });
650
+ if ('summary' in result && result.summary) {
651
+ result.summary.diagnosticIds = diagnosticIds;
652
+ result.summary.effects = projected.effects;
653
+ for (const target of result.summary.targets ?? []) {
654
+ const targetDiagnostics = projected.diagnostics.filter((diagnostic) => diagnostic.target
655
+ && diagnostic.target.platform === target.platform
656
+ && diagnostic.target.scope === target.scope
657
+ && (diagnostic.target.pluginId === undefined || diagnostic.target.pluginId === target.pluginId));
658
+ target.diagnosticIds = targetDiagnostics.map((diagnostic) => diagnostic.id);
659
+ target.effects = projected.targetEffects?.get(`${target.pluginId ?? ''}\0${target.platform}\0${target.scope}`);
660
+ }
661
+ }
662
+ const code = result.state === 'succeeded' || result.state === 'partial'
663
+ ? result.summary.code : result.summary?.code ?? result.code;
664
+ const message = result.state === 'succeeded' || result.state === 'partial'
665
+ ? result.summary.message : result.summary?.message ?? result.message;
666
+ this.#operationHistory.record({
667
+ summary: {
668
+ recordId,
669
+ epoch: this.#sessionEpoch,
670
+ operationId: result.operationId,
671
+ createdAtMs: this.dependencies.now?.() ?? Date.now(),
672
+ state: result.state,
673
+ code,
674
+ message,
675
+ ...('operationKind' in result && result.operationKind ? { operationKind: result.operationKind } : {}),
676
+ ...('pluginId' in result && result.pluginId ? { pluginId: result.pluginId } : {}),
677
+ diagnosticCount: projected.diagnostics.length,
678
+ effects: projected.effects,
679
+ },
680
+ diagnostics: projected.diagnostics,
681
+ targets: 'summary' in result && result.summary
682
+ ? structuredClone((result.summary.targets ?? []).map((target) => ({
683
+ ...target,
684
+ diagnosticIds: target.diagnosticIds ?? [],
685
+ })))
686
+ : structuredClone(projected.targets ?? []),
687
+ }, { sensitiveValues: projected.sensitiveValues });
688
+ this.#operationDiagnostics.delete(result.operationId);
689
+ this.#lastRecordedResultId = recordId;
690
+ }
691
+ #captureThrownProjection(operationId, error, code, stage, intent, effects) {
692
+ const sensitiveValues = intent ? collectOperationSensitiveValues(intent) : [];
693
+ this.#operationDiagnostics.set(operationId, {
694
+ diagnostics: [projectThrownOperationDiagnostic({
695
+ error,
696
+ id: `${this.#sessionEpoch}:${operationId}:failure`,
697
+ stage,
698
+ fallbackCode: code,
699
+ sensitiveValues,
700
+ ...(intent && 'pluginId' in intent && 'platform' in intent && intent.platform !== 'both'
701
+ ? { target: { pluginId: intent.pluginId, platform: intent.platform, scope: intent.scope } }
702
+ : {}),
703
+ })],
704
+ effects: effects ?? {
705
+ configCommit: 'notApplicable', awWrite: 'notStarted',
706
+ preApplyExternalEffect: 'notApplicable', finalVerification: 'notStarted',
707
+ },
708
+ sensitiveValues,
709
+ });
710
+ }
288
711
  async #logErrorSafe(error, code, context) {
289
712
  try {
290
713
  await this.dependencies.logError?.(error, code, context);
@@ -306,64 +729,152 @@ export class TuiController {
306
729
  catalogGenerations: this.#catalogGenerations,
307
730
  dependencyOverviews: this.#status.dependencyOverviews,
308
731
  portablePluginIds: this.#status.portablePluginIds,
732
+ configStoreInventory: this.#status.configStoreInventory,
309
733
  });
310
734
  }
311
735
  #normalizeRoute(route) {
312
- if (route.name === 'pluginList' && route.sourceId
313
- && !this.#viewModel?.sourceTabs.some((source) => source.id === route.sourceId)) {
314
- return { name: 'pluginList', mode: route.mode };
736
+ return normalizeTuiRoute(route, this.#routeEntityState());
737
+ }
738
+ #routeEntityState() {
739
+ return {
740
+ hasPlugin: (pluginId) => this.#viewModel?.plugins.some((plugin) => plugin.pluginId === pluginId) ?? false,
741
+ hasSource: (sourceId) => this.#viewModel?.sourceTabs.some((source) => source.id === sourceId) ?? false,
742
+ hasIssue: (issueId) => this.#viewModel?.issues.some((issue) => issue.issueId === issueId) ?? false,
743
+ hasHistoryRecord: (recordId) => this.#operationHistory.get(recordId) !== undefined,
744
+ hasActiveOperation: (candidate) => candidate.name === 'reviewPreparing'
745
+ ? this.#reviewPreparation?.snapshot.id === candidate.preparationId
746
+ : candidate.name === 'result'
747
+ ? this.#result?.operationId === candidate.operationId
748
+ : this.#activeReview?.operationId === candidate.operationId,
749
+ };
750
+ }
751
+ #normalizeCurrentRoute() {
752
+ this.#router.normalizeRoutes((route) => this.#normalizeRoute(route));
753
+ if (this.#resultAnchor)
754
+ this.#resultAnchor = this.#normalizeRoute(this.#resultAnchor);
755
+ for (const ticket of this.#tickets.values())
756
+ ticket.returnRoute = this.#normalizeRoute(ticket.returnRoute);
757
+ if (this.#reviewPreparation) {
758
+ this.#reviewPreparation.returnRoute = this.#normalizeRoute(this.#reviewPreparation.returnRoute);
759
+ }
760
+ }
761
+ #ensureOperationFlow(intent) {
762
+ const active = this.#router.activeFlow();
763
+ if (intent.kind === 'pluginWriteBatch') {
764
+ if (active?.kind === 'batchWrite')
765
+ return;
766
+ this.#router.beginFlow('batchWrite', { name: 'pluginList', mode: 'installed' });
767
+ return;
315
768
  }
316
- const pluginRoute = route.name === 'pluginPreview'
317
- || route.name === 'portableSkillSelect'
318
- || route.name === 'portableDependencies'
319
- || route.name === 'portableAgentMode'
320
- || route.name === 'portableAgentInput'
321
- || route.name === 'portableScope'
322
- || route.name === 'portableBlocker'
323
- || route.name === 'pluginDependencies'
324
- || route.name === 'installPlacement'
325
- || route.name === 'installTargets'
326
- || route.name === 'dependencyPlacement'
327
- || route.name === 'dependencyPlacementEditor'
328
- || route.name === 'pluginDetail'
329
- || route.name === 'platform'
330
- || route.name === 'pluginAbout'
331
- || route.name === 'pluginTechnical'
332
- || route.name === 'configLayer'
333
- || route.name === 'configFields'
334
- || route.name === 'configFieldEditor'
335
- || route.name === 'configUnavailable';
336
- if (pluginRoute && !this.#viewModel?.plugins.some((plugin) => plugin.pluginId === route.pluginId)) {
337
- return {
338
- name: 'pluginList',
339
- mode: route.name === 'pluginPreview' || route.name === 'pluginDependencies'
340
- || route.name === 'installPlacement' || route.name === 'installTargets'
341
- || route.name === 'dependencyPlacement' || route.name === 'dependencyPlacementEditor'
342
- ? 'browse'
343
- : 'installed',
344
- };
769
+ if (intent.kind === 'pluginWrite') {
770
+ const kind = intent.action === 'install' ? 'pluginInstall' : 'pluginMaintenance';
771
+ if (active?.kind === kind)
772
+ return;
773
+ this.#router.beginPluginFlow(kind, intent.pluginId);
774
+ return;
345
775
  }
346
- if (route.name === 'issueDetail'
347
- && !this.#viewModel?.issues.some((issue) => issue.issueId === route.issueId)) {
348
- return { name: 'issueList' };
776
+ if (intent.kind === 'portableInstall') {
777
+ if (active?.kind === 'portableInstall')
778
+ return;
779
+ this.#router.beginPortableFlow(intent.pluginId);
780
+ return;
349
781
  }
350
- if ((route.name === 'sourceDetail'
351
- || route.name === 'sourceTechnical'
352
- || route.name === 'sourceEdit'
353
- || route.name === 'sourceRemoveBlocked')
354
- && !this.#viewModel?.sourceTabs.some((source) => source.id === route.sourceId)) {
355
- return { name: 'sourceList' };
782
+ if (intent.kind === 'configSave' || intent.kind === 'configReset') {
783
+ if (active?.kind === 'configEdit')
784
+ return;
785
+ const route = this.#router.snapshot.current.route;
786
+ if ((route.name === 'pluginPreview' || route.name === 'pluginDetail')
787
+ && route.pluginId === intent.pluginId) {
788
+ this.#router.beginConfigFlow(intent.pluginId);
789
+ }
790
+ else {
791
+ this.#router.beginFlow('configEdit', { name: 'pluginDetail', pluginId: intent.pluginId });
792
+ }
793
+ return;
356
794
  }
357
- return route;
358
- }
359
- #normalizeCurrentRoute() {
360
- const route = this.#router.snapshot.current.route;
361
- const normalized = this.#normalizeRoute(route);
362
- if (normalized !== route)
363
- this.#router.replace(normalized);
795
+ const kind = intent.kind === 'catalogSourcesRecovery' ? 'sourceRecovery' : 'sourceEdit';
796
+ if (active?.kind === 'sourceRecovery'
797
+ && (intent.kind === 'catalogSourcesRecovery'
798
+ || intent.kind === 'catalogGeneration'
799
+ || intent.kind === 'sourceMutation'))
800
+ return;
801
+ if (active?.kind === kind)
802
+ return;
803
+ this.#router.beginFlow(kind, active ? this.#router.snapshot.current.route
804
+ : intent.kind === 'sourceMutation' && intent.action !== 'remove'
805
+ ? { name: 'sourceDetail', sourceId: intent.sourceId }
806
+ : intent.kind === 'catalogGeneration'
807
+ ? { name: 'sourceDetail', sourceId: intent.sourceId }
808
+ : { name: 'sourceList' });
809
+ }
810
+ #stableResultRoute(intent, result) {
811
+ const active = this.#router.activeFlow();
812
+ if (active?.parentFlowId)
813
+ return this.#normalizeRoute(active.returnTarget);
814
+ if (active && (intent.kind === 'portableInstall'
815
+ || intent.kind === 'configSave' || intent.kind === 'configReset')) {
816
+ return this.#normalizeRoute(active.returnTarget);
817
+ }
818
+ if (intent.kind === 'pluginWrite') {
819
+ if (intent.originIssue && result.state === 'succeeded')
820
+ return { name: 'issueList' };
821
+ return this.#normalizeRoute({ name: 'pluginDetail', pluginId: intent.pluginId });
822
+ }
823
+ if (intent.kind === 'pluginWriteBatch')
824
+ return { name: 'pluginList', mode: 'installed' };
825
+ if (intent.kind === 'portableInstall' || intent.kind === 'configSave' || intent.kind === 'configReset') {
826
+ return this.#normalizeRoute({ name: 'pluginDetail', pluginId: intent.pluginId });
827
+ }
828
+ if (intent.kind === 'sourceMutation' && intent.action === 'update') {
829
+ return this.#normalizeRoute({ name: 'sourceDetail', sourceId: intent.sourceId });
830
+ }
831
+ if (intent.kind === 'catalogGeneration') {
832
+ return this.#normalizeRoute({ name: 'sourceDetail', sourceId: intent.sourceId });
833
+ }
834
+ return { name: 'sourceList' };
835
+ }
836
+ #showResult(result, intent) {
837
+ const terminal = { name: 'result', operationId: result.operationId };
838
+ const target = this.#stableResultRoute(intent, result);
839
+ this.#resultAnchor = structuredClone(target);
840
+ this.#cancelReviewPreparation();
841
+ this.#progress = { messages: [] };
842
+ this.#progressTargets = [];
843
+ this.#recoverableInstallPlacementDraft = result.state === 'blocked'
844
+ && intent.kind === 'pluginWrite' && intent.action === 'install'
845
+ && this.#installPlacementDraft?.pluginId === intent.pluginId
846
+ ? structuredClone(this.#installPlacementDraft)
847
+ : undefined;
848
+ this.#installPlacementDraft = undefined;
849
+ this.#recoverablePortableDraft = result.state !== 'succeeded'
850
+ && intent.kind === 'portableInstall'
851
+ && this.#portableDraft?.pluginId === intent.pluginId
852
+ ? structuredClone(this.#portableDraft)
853
+ : undefined;
854
+ this.#portableResultSkills = intent.kind === 'portableInstall'
855
+ ? this.#portableDraft?.candidates
856
+ .filter((candidate) => candidate.selected)
857
+ .map((candidate) => candidate.name)
858
+ : undefined;
859
+ this.#portableDraft = undefined;
860
+ this.#batchRetryIntent = intent.kind === 'pluginWriteBatch'
861
+ && 'summary' in result
862
+ && result.summary?.batch
863
+ && (result.summary.batch.retryTargets?.length ?? 0) > 0
864
+ ? {
865
+ ...structuredClone(intent),
866
+ selection: {
867
+ kind: 'targets',
868
+ targets: structuredClone(result.summary.batch.retryTargets),
869
+ },
870
+ }
871
+ : undefined;
872
+ if (!this.#router.finishFlow(target, terminal))
873
+ this.#router.replace(terminal);
364
874
  }
365
875
  #applyStatus(status) {
366
876
  this.#status = structuredClone(status);
877
+ this.#statusReadAtMs = this.dependencies.now?.() ?? Date.now();
367
878
  if (status.catalogGenerations) {
368
879
  this.#catalogGenerations.clear();
369
880
  for (const source of status.catalogGenerations) {
@@ -421,13 +932,14 @@ export class TuiController {
421
932
  this.#normalizeCurrentRoute();
422
933
  }
423
934
  async load() {
935
+ this.#assertAcceptingActions();
424
936
  this.dependencies.beginCatalogSession?.();
425
937
  const generation = ++this.#generation;
426
938
  this.#phase = 'loading';
427
939
  this.#publish();
428
940
  let status;
429
941
  try {
430
- status = await this.dependencies.loadStatus(this.#abort.signal);
942
+ status = await this.#trackRead(this.dependencies.loadStatus(this.#abort.signal));
431
943
  }
432
944
  catch (error) {
433
945
  if (this.#disposed || generation !== this.#generation)
@@ -436,6 +948,7 @@ export class TuiController {
436
948
  await this.#logErrorSafe(error, code);
437
949
  this.#phase = 'error';
438
950
  this.#loadError = { code, message: '无法安全读取插件状态。请检查诊断后重试或退出。' };
951
+ this.#recordStandaloneFailure(code, 'statusReload', error);
439
952
  this.#publish();
440
953
  return;
441
954
  }
@@ -451,6 +964,10 @@ export class TuiController {
451
964
  this.#cancelPortableRequest();
452
965
  this.#cancelInstallPlacementRequest();
453
966
  this.#discardPortableDraftForRoute(route);
967
+ if ((route.name === 'sourceAdd' || route.name === 'sourceEdit' || route.name === 'sourceSave')
968
+ && this.#router.activeFlow()?.kind !== 'sourceEdit') {
969
+ this.#router.beginFlow('sourceEdit', this.#router.snapshot.current.route);
970
+ }
454
971
  this.#router.push(route, context);
455
972
  this.#publish();
456
973
  }
@@ -458,6 +975,7 @@ export class TuiController {
458
975
  return this.#status?.portablePluginIds?.includes(pluginId) ?? false;
459
976
  }
460
977
  preparePortableInstall(pluginId, navigation = 'push') {
978
+ this.#assertAcceptingActions();
461
979
  if (!this.hasPortableSkills(pluginId)) {
462
980
  throw new TuiOperationBlockedError('portableUnsupported', '插件不提供通用 Skills。');
463
981
  }
@@ -473,6 +991,7 @@ export class TuiController {
473
991
  });
474
992
  }
475
993
  refreshPortableInstallDraft(pluginId) {
994
+ this.#assertAcceptingActions();
476
995
  const expectedRoute = this.#router.snapshot.current.route;
477
996
  return this.#runPortableRequest(`refresh:${pluginId}`, async (signal, token) => {
478
997
  const draft = await this.#buildPortableDraft(pluginId, true, signal);
@@ -488,12 +1007,12 @@ export class TuiController {
488
1007
  : undefined;
489
1008
  const prepared = await Promise.allSettled(['project', 'user'].map(async (scope) => ({
490
1009
  scope,
491
- evidence: await this.dependencies.captureEvidence({
1010
+ evidence: await this.#trackRead(this.dependencies.captureEvidence({
492
1011
  kind: 'portableInstall',
493
1012
  pluginId,
494
1013
  scope,
495
1014
  selectedSkills: [],
496
- }, signal),
1015
+ }, signal)),
497
1016
  })));
498
1017
  const available = prepared.flatMap((entry) => entry.status === 'fulfilled' && entry.value.evidence.portable ? [entry.value] : []);
499
1018
  if (available.length === 0) {
@@ -639,8 +1158,91 @@ export class TuiController {
639
1158
  });
640
1159
  this.#publish();
641
1160
  }
1161
+ beginSavedConfigRecovery(pluginId) {
1162
+ this.#assertAcceptingActions();
1163
+ const plugin = this.#viewModel?.plugins.find((entry) => entry.pluginId === pluginId);
1164
+ if (!plugin?.configOnly || plugin.savedConfigScopes.length === 0) {
1165
+ throw new TuiOperationBlockedError('savedConfigUnavailable', '没有可识别的已保存设置。');
1166
+ }
1167
+ this.#router.beginConfigFlow(pluginId);
1168
+ this.#router.push({
1169
+ name: 'configUnavailable',
1170
+ pluginId,
1171
+ storedScopes: plugin.savedConfigScopes,
1172
+ ...(plugin.savedConfigScopes.length === 1
1173
+ ? { layer: { scope: plugin.savedConfigScopes[0] } }
1174
+ : {}),
1175
+ diagnosticCode: 'catalogSourceUnavailable',
1176
+ diagnosticStage: 'read-config',
1177
+ });
1178
+ this.#publish();
1179
+ }
1180
+ beginConfigSourceRecovery(sourceId) {
1181
+ this.#assertAcceptingActions();
1182
+ const route = this.#router.snapshot.current.route;
1183
+ const active = this.#router.activeFlow();
1184
+ if (route.name !== 'configUnavailable'
1185
+ || active?.kind !== 'configEdit'
1186
+ || route.storedScopes !== undefined
1187
+ || sourceIdFromPluginId(route.pluginId) !== sourceId
1188
+ || !this.#viewModel?.sourceTabs.some((source) => source.id === sourceId)) {
1189
+ throw new TuiOperationBlockedError('configSourceRecoveryUnavailable', '来源恢复只能从不可用的设置流程开始。');
1190
+ }
1191
+ const layer = route.layer?.scope ?? route.resumeRoute?.layer.scope ?? 'effective';
1192
+ const returnTarget = route.resumeRoute ?? {
1193
+ name: 'configFields',
1194
+ pluginId: route.pluginId,
1195
+ layer: { scope: layer },
1196
+ };
1197
+ this.#router.beginFlow('sourceRecovery', returnTarget, undefined, route.resumeContext);
1198
+ this.#router.push({ name: 'sourceDetail', sourceId });
1199
+ this.#publish();
1200
+ }
1201
+ async getForceCleanupCandidate(pluginId, platform, scope) {
1202
+ this.#assertAcceptingActions();
1203
+ const intent = {
1204
+ kind: 'pluginWrite', action: 'cleanup', pluginId, platform, scope, force: true,
1205
+ };
1206
+ try {
1207
+ const evidence = await this.#trackRead(this.dependencies.captureEvidence(intent, this.#abort.signal));
1208
+ const candidate = evidence.offlineCleanup;
1209
+ if (candidate?.forceExclusive !== true
1210
+ || candidate.pluginId !== pluginId
1211
+ || candidate.platform !== platform
1212
+ || candidate.scope !== scope
1213
+ || candidate.plan.actions.length === 0)
1214
+ return undefined;
1215
+ return cloneFrozen({ pluginId, platform, scope, actionCount: candidate.plan.actions.length });
1216
+ }
1217
+ catch (error) {
1218
+ await this.#logOperationError(error, issueCode(error), intent);
1219
+ return undefined;
1220
+ }
1221
+ }
1222
+ startForceCleanupReview(candidate) {
1223
+ if (candidate.actionCount <= 0) {
1224
+ throw new TuiOperationBlockedError('forceCleanupPathUnavailable', '没有可验证的精确清理目标。');
1225
+ }
1226
+ return this.startReviewPreparation({
1227
+ kind: 'pluginWrite',
1228
+ action: 'cleanup',
1229
+ pluginId: candidate.pluginId,
1230
+ platform: candidate.platform,
1231
+ scope: candidate.scope,
1232
+ force: true,
1233
+ });
1234
+ }
642
1235
  configOrigin(pluginId) {
643
- return this.#router.configOrigin(pluginId);
1236
+ const origin = this.#router.configOrigin(pluginId);
1237
+ if (origin)
1238
+ return origin;
1239
+ if (this.#router.snapshot.current.route.name === 'result'
1240
+ && this.#resultAnchor
1241
+ && (this.#resultAnchor.name === 'pluginPreview' || this.#resultAnchor.name === 'pluginDetail')
1242
+ && this.#resultAnchor.pluginId === pluginId) {
1243
+ return structuredClone(this.#resultAnchor);
1244
+ }
1245
+ return undefined;
644
1246
  }
645
1247
  updatePageContext(context) {
646
1248
  this.#router.updateContext(context);
@@ -648,6 +1250,7 @@ export class TuiController {
648
1250
  }
649
1251
  back() {
650
1252
  const route = this.#router.snapshot.current.route;
1253
+ const activeFlow = this.#router.activeFlow();
651
1254
  this.#cancelReviewPreparation();
652
1255
  this.#cancelPortableRequest();
653
1256
  this.#cancelInstallPlacementRequest();
@@ -656,6 +1259,14 @@ export class TuiController {
656
1259
  this.#activeReview = undefined;
657
1260
  }
658
1261
  this.#router.back();
1262
+ if (activeFlow
1263
+ && this.#router.snapshot.currentFrameId === activeFlow.originFrameId) {
1264
+ this.#router.abandonFlow(activeFlow.id);
1265
+ if (activeFlow.kind === 'pluginInstall')
1266
+ this.#installPlacementDraft = undefined;
1267
+ if (activeFlow.kind === 'portableInstall')
1268
+ this.#portableDraft = undefined;
1269
+ }
659
1270
  this.#normalizeCurrentRoute();
660
1271
  this.#discardPortableDraftForRoute(this.#router.snapshot.current.route);
661
1272
  this.#publish();
@@ -666,13 +1277,34 @@ export class TuiController {
666
1277
  this.#activeReview = undefined;
667
1278
  }
668
1279
  this.#result = undefined;
669
- this.#router.discardSourceFlow(owner, returnRoute);
1280
+ const activeFlow = this.#router.activeFlow();
1281
+ if (activeFlow?.kind === 'sourceEdit') {
1282
+ this.#router.abandonFlow(activeFlow.id);
1283
+ if (JSON.stringify(this.#router.snapshot.current.route) !== JSON.stringify(returnRoute)) {
1284
+ this.#router.replace(returnRoute);
1285
+ }
1286
+ }
1287
+ else {
1288
+ this.#router.discardSourceFlow(owner, returnRoute);
1289
+ }
670
1290
  this.#normalizeCurrentRoute();
671
1291
  this.#publish();
672
1292
  }
673
1293
  leaveResult(target) {
674
1294
  if (this.#router.snapshot.current.route.name !== 'result')
675
1295
  return;
1296
+ if (target?.name === 'home') {
1297
+ this.#router.resetToRoot();
1298
+ this.#portableDraft = undefined;
1299
+ this.#recoverablePortableDraft = undefined;
1300
+ this.#installPlacementDraft = undefined;
1301
+ this.#recoverableInstallPlacementDraft = undefined;
1302
+ this.#result = undefined;
1303
+ this.#resultAnchor = undefined;
1304
+ this.#portableResultSkills = undefined;
1305
+ this.#publish();
1306
+ return;
1307
+ }
676
1308
  if (this.#result?.operationKind === 'portableInstall'
677
1309
  && 'pluginId' in this.#result && this.#result.pluginId) {
678
1310
  const preserveDraft = target?.name === 'portableSkillSelect'
@@ -688,19 +1320,55 @@ export class TuiController {
688
1320
  this.#publish();
689
1321
  return;
690
1322
  }
691
- const route = target ?? (this.#result && 'returnRoute' in this.#result
692
- ? this.#result.returnRoute
693
- : undefined);
694
1323
  this.#router.back();
695
- if (route)
696
- this.#router.replace(this.#normalizeRoute(route));
1324
+ if (target)
1325
+ this.#router.replace(this.#normalizeRoute(target));
697
1326
  else
698
1327
  this.#normalizeCurrentRoute();
699
1328
  this.#discardPortableDraftForRoute(this.#router.snapshot.current.route);
700
1329
  this.#result = undefined;
1330
+ this.#resultAnchor = undefined;
1331
+ this.#portableResultSkills = undefined;
1332
+ this.#recoverableInstallPlacementDraft = undefined;
1333
+ this.#recoverablePortableDraft = undefined;
1334
+ this.#publish();
1335
+ }
1336
+ resumeResultDraft() {
1337
+ const result = this.#result;
1338
+ if (this.#router.snapshot.current.route.name !== 'result' || !result
1339
+ || !('returnRoute' in result) || !result.returnRoute)
1340
+ return;
1341
+ const returnRoute = this.#normalizeRoute(result.returnRoute);
1342
+ this.#router.back();
1343
+ const kind = result.operationKind === 'configSave' || result.operationKind === 'configReset'
1344
+ ? 'configEdit'
1345
+ : result.operationKind === 'portableInstall'
1346
+ ? 'portableInstall'
1347
+ : result.operationKind === 'sourceMutation' || result.operationKind === 'catalogGeneration'
1348
+ || result.operationKind === 'catalogSourcesRecovery'
1349
+ ? 'sourceEdit'
1350
+ : 'pluginMaintenance';
1351
+ this.#router.beginFlow(kind, this.#router.snapshot.current.route);
1352
+ if (result.operationKind === 'pluginWrite') {
1353
+ this.#installPlacementDraft = this.#recoverableInstallPlacementDraft;
1354
+ }
1355
+ if (result.operationKind === 'portableInstall') {
1356
+ this.#portableDraft = this.#recoverablePortableDraft;
1357
+ }
1358
+ this.#router.push(returnRoute);
1359
+ this.#recoverableInstallPlacementDraft = undefined;
1360
+ this.#recoverablePortableDraft = undefined;
1361
+ this.#result = undefined;
1362
+ this.#resultAnchor = undefined;
1363
+ this.#portableResultSkills = undefined;
701
1364
  this.#publish();
702
1365
  }
703
1366
  #discardPortableDraftForRoute(route) {
1367
+ const readOnlyHistoryRoute = route.name === 'operationHistory'
1368
+ || route.name === 'operationDiagnostic'
1369
+ || route.name === 'operationReportPreview';
1370
+ if (readOnlyHistoryRoute && this.#router.activeFlow()?.kind === 'portableInstall')
1371
+ return;
704
1372
  const portableRoute = route.name === 'portableSkillSelect'
705
1373
  || route.name === 'portableDependencies'
706
1374
  || route.name === 'portableAgentMode'
@@ -709,7 +1377,8 @@ export class TuiController {
709
1377
  || route.name === 'portableBlocker';
710
1378
  const portableOperationRoute = (route.name === 'reviewPreparing' || route.name === 'review'
711
1379
  || route.name === 'progress' || route.name === 'result')
712
- && (this.#reviewPreparation?.snapshot.intent.kind === 'portableInstall'
1380
+ && (this.#reviewPreparation !== undefined
1381
+ && this.#reviewPreparationIntents.get(this.#reviewPreparation.snapshot.id)?.kind === 'portableInstall'
713
1382
  || this.#activeReview?.operationKind === 'portableInstall'
714
1383
  || this.#result?.operationKind === 'portableInstall');
715
1384
  if (!portableRoute && !portableOperationRoute) {
@@ -719,15 +1388,24 @@ export class TuiController {
719
1388
  }
720
1389
  resetPortableFlowToPreview(pluginId, target, preserveDraft = false) {
721
1390
  this.#cancelPortableRequest();
1391
+ if (preserveDraft && !this.#portableDraft) {
1392
+ this.#portableDraft = this.#recoverablePortableDraft;
1393
+ }
722
1394
  if (!preserveDraft)
723
1395
  this.#portableDraft = undefined;
1396
+ this.#recoverablePortableDraft = undefined;
724
1397
  if (this.#activeReview?.operationKind === 'portableInstall') {
725
1398
  this.#tickets.delete(this.#activeReview.reviewToken);
726
1399
  this.#activeReview = undefined;
727
1400
  }
728
1401
  if (this.#result?.operationKind === 'portableInstall')
729
1402
  this.#result = undefined;
730
- this.#router.resetPortableFlow(pluginId);
1403
+ this.#resultAnchor = undefined;
1404
+ this.#portableResultSkills = undefined;
1405
+ if (this.#router.snapshot.current.route.name === 'result')
1406
+ this.#router.back();
1407
+ else
1408
+ this.#router.resetPortableFlow(pluginId);
731
1409
  if (target && JSON.stringify(this.#router.snapshot.current.route) !== JSON.stringify(target)) {
732
1410
  this.#router.beginPortableFlow(pluginId);
733
1411
  this.#router.push(this.#normalizeRoute(target));
@@ -766,6 +1444,9 @@ export class TuiController {
766
1444
  }
767
1445
  beginInstallFlow(pluginId, rootScope, options = {}) {
768
1446
  this.#cancelInstallPlacementRequest();
1447
+ const active = this.#router.activeFlow();
1448
+ if (active?.kind !== 'pluginInstall')
1449
+ this.#router.beginPluginFlow('pluginInstall', pluginId);
769
1450
  this.#installPlacementDraft = {
770
1451
  pluginId,
771
1452
  rootScope,
@@ -838,6 +1519,7 @@ export class TuiController {
838
1519
  };
839
1520
  }
840
1521
  async prepareInstallDependencyPlacement() {
1522
+ this.#assertAcceptingActions();
841
1523
  const draft = this.#installPlacementDraft;
842
1524
  if (!draft)
843
1525
  throw new TuiOperationBlockedError('installPlacementMissing', '安装位置草稿不存在。');
@@ -859,7 +1541,7 @@ export class TuiController {
859
1541
  throw new DOMException('Dependency placement preview was cancelled.', 'AbortError');
860
1542
  }
861
1543
  };
862
- const plan = await preview(this.#installPlacementIntent());
1544
+ const plan = await this.#trackRead(preview(this.#installPlacementIntent()));
863
1545
  throwIfCancelled();
864
1546
  if (!plan) {
865
1547
  draft.state = 'ready';
@@ -925,7 +1607,7 @@ export class TuiController {
925
1607
  listPlugins(options) {
926
1608
  const query = options.query?.trim().toLocaleLowerCase('en');
927
1609
  return cloneFrozen((this.#viewModel?.plugins ?? []).filter((plugin) => (!options.sourceId || plugin.sourceId === options.sourceId)
928
- && (options.mode === 'browse' || hasInstalledPlacement(plugin))
1610
+ && (options.mode === 'browse' || hasInstalledPlacement(plugin) || plugin.savedConfigScopes.length > 0)
929
1611
  && (options.installFilter !== 'uninstalled' || !hasInstalledPlacement(plugin))
930
1612
  && (!query || `${plugin.displayName}\n${plugin.description}\n${plugin.sourceId}`.toLocaleLowerCase('en').includes(query))));
931
1613
  }
@@ -1026,6 +1708,10 @@ export class TuiController {
1026
1708
  health: this.#health.get(sourceId),
1027
1709
  });
1028
1710
  }
1711
+ getConfiguredSource(sourceId) {
1712
+ const source = this.#status?.configuredSources?.find((candidate) => candidate.id === sourceId);
1713
+ return source ? cloneFrozen(source) : undefined;
1714
+ }
1029
1715
  getCatalogGeneration(sourceId) {
1030
1716
  const source = this.#catalogGenerations.get(sourceId);
1031
1717
  return source ? cloneFrozen(source) : undefined;
@@ -1035,17 +1721,70 @@ export class TuiController {
1035
1721
  return freshness ? cloneFrozen(freshness) : undefined;
1036
1722
  }
1037
1723
  recheckCatalogSource(sourceId) {
1724
+ this.#assertAcceptingActions();
1038
1725
  return this.#catalogSourcesSession.recheck(sourceId);
1039
1726
  }
1727
+ async recheckAllCatalogSources() {
1728
+ this.#assertAcceptingActions();
1729
+ const sourceIds = [...this.listSources()]
1730
+ .sort((left, right) => Number(Boolean(right.primary)) - Number(Boolean(left.primary)))
1731
+ .map((source) => source.id);
1732
+ let next = 0;
1733
+ const worker = async () => {
1734
+ while (next < sourceIds.length) {
1735
+ const sourceId = sourceIds[next++];
1736
+ if (sourceId)
1737
+ await this.#catalogSourcesSession.recheck(sourceId);
1738
+ }
1739
+ };
1740
+ await Promise.all(Array.from({ length: Math.min(2, sourceIds.length) }, () => worker()));
1741
+ }
1040
1742
  fullValidateCatalogSource(sourceId) {
1743
+ this.#assertAcceptingActions();
1041
1744
  return this.#catalogSourcesSession.fullValidate(sourceId);
1042
1745
  }
1043
- setExternalDraftState(state) {
1044
- this.#externalDraft = state;
1746
+ setExternalDraftState(states = []) {
1747
+ const priority = { sourceDraft: 0, batchDraft: 1, configDraft: 2 };
1748
+ this.#externalDrafts = structuredClone(states).sort((left, right) => priority[left.kind] - priority[right.kind] || left.owner.localeCompare(right.owner, 'en'));
1749
+ this.#publish();
1750
+ }
1751
+ beginPluginWriteBatch() {
1752
+ this.#assertAcceptingActions();
1753
+ this.#router.beginBatchFlow();
1754
+ this.#router.push({ name: 'batchSelection' });
1755
+ this.#publish();
1756
+ }
1757
+ startPluginWriteBatchReview(intent) {
1758
+ return this.startReviewPreparation(intent, { name: 'batchPlacement' });
1759
+ }
1760
+ retryFailedPluginWriteBatch() {
1761
+ if (!this.#batchRetryIntent)
1762
+ return undefined;
1763
+ const intent = structuredClone(this.#batchRetryIntent);
1764
+ this.#batchRetryIntent = undefined;
1765
+ return this.startReviewPreparation(intent, { name: 'pluginList', mode: 'installed' });
1045
1766
  }
1046
1767
  catalogActivationBlockReason() {
1047
- if (this.#externalDraft)
1048
- return this.#externalDraft;
1768
+ if (this.#externalDrafts[0])
1769
+ return this.#externalDrafts[0].kind;
1770
+ if (this.#portableDraft)
1771
+ return 'portableDraft';
1772
+ if (this.#reviewPreparation)
1773
+ return 'reviewPreparation';
1774
+ if (this.#activeReview)
1775
+ return 'activeReview';
1776
+ if (this.#router.snapshot.current.route.name === 'progress'
1777
+ || this.#inflightOperations.size > 0)
1778
+ return 'writeProgress';
1779
+ return undefined;
1780
+ }
1781
+ catalogSourceRecoveryBlockReason() {
1782
+ const activeFlow = this.#router.activeFlow();
1783
+ const blockingExternalDraft = this.#externalDrafts.find((draft) => !(draft.kind === 'configDraft'
1784
+ && activeFlow?.kind === 'sourceRecovery'
1785
+ && activeFlow.parentFlowId !== undefined));
1786
+ if (blockingExternalDraft)
1787
+ return blockingExternalDraft.kind;
1049
1788
  if (this.#portableDraft)
1050
1789
  return 'portableDraft';
1051
1790
  if (this.#reviewPreparation)
@@ -1061,6 +1800,7 @@ export class TuiController {
1061
1800
  return this.#status?.payload.catalog.sources.find((source) => source.id === sourceId)?.catalogVersion;
1062
1801
  }
1063
1802
  async reloadCatalog(options) {
1803
+ this.#assertAcceptingActions();
1064
1804
  if (this.catalogActivationBlockReason())
1065
1805
  return false;
1066
1806
  this.dependencies.beginCatalogSession?.();
@@ -1068,7 +1808,7 @@ export class TuiController {
1068
1808
  this.#phase = 'loading';
1069
1809
  this.#publish();
1070
1810
  try {
1071
- const status = await this.dependencies.loadStatus(this.#abort.signal);
1811
+ const status = await this.#trackRead(this.dependencies.loadStatus(this.#abort.signal));
1072
1812
  if (this.#disposed || generation !== this.#generation)
1073
1813
  return false;
1074
1814
  this.#applyStatus(status);
@@ -1090,39 +1830,45 @@ export class TuiController {
1090
1830
  }
1091
1831
  }
1092
1832
  async inspectSourceRemoval(sourceId) {
1833
+ this.#assertAcceptingActions();
1093
1834
  if (!this.dependencies.inspectSourceRemoval) {
1094
1835
  throw new TuiOperationBlockedError('stateInventoryFailed', '来源引用清单不可用。');
1095
1836
  }
1096
- return cloneFrozen(await this.dependencies.inspectSourceRemoval(sourceId, this.#abort.signal));
1837
+ return cloneFrozen(await this.#trackRead(this.dependencies.inspectSourceRemoval(sourceId, this.#abort.signal)));
1097
1838
  }
1098
1839
  async getConfigGroups(pluginId, layer) {
1840
+ this.#assertAcceptingActions();
1099
1841
  if (!this.dependencies.getConfigGroups) {
1100
1842
  throw new TuiOperationBlockedError('configUnavailable', '设置读取入口不可用。');
1101
1843
  }
1102
- return cloneFrozen(await this.dependencies.getConfigGroups(pluginId, structuredClone(layer), this.#abort.signal));
1844
+ return cloneFrozen(await this.#trackRead(this.dependencies.getConfigGroups(pluginId, structuredClone(layer), this.#abort.signal)));
1103
1845
  }
1104
1846
  async getConfigPage(pluginId, layer, signal) {
1847
+ this.#assertAcceptingActions();
1105
1848
  if (!this.dependencies.getConfigPage) {
1106
1849
  throw new TuiOperationBlockedError('configUnavailable', '设置读取入口不可用。');
1107
1850
  }
1108
1851
  const combined = signal
1109
1852
  ? AbortSignal.any([this.#abort.signal, signal])
1110
1853
  : this.#abort.signal;
1111
- return cloneFrozen(await this.dependencies.getConfigPage(pluginId, structuredClone(layer), combined));
1854
+ return cloneFrozen(await this.#trackRead(this.dependencies.getConfigPage(pluginId, structuredClone(layer), combined)));
1112
1855
  }
1113
1856
  async getConfigResetAvailability(pluginId, layer) {
1857
+ this.#assertAcceptingActions();
1114
1858
  if (!this.dependencies.getConfigResetAvailability) {
1115
1859
  throw new TuiOperationBlockedError('configResetVerificationUnavailable', '设置层可信读取入口不可用。');
1116
1860
  }
1117
- return cloneFrozen(await this.dependencies.getConfigResetAvailability(pluginId, structuredClone(layer), this.#abort.signal));
1861
+ return cloneFrozen(await this.#trackRead(this.dependencies.getConfigResetAvailability(pluginId, structuredClone(layer), this.#abort.signal)));
1118
1862
  }
1119
1863
  listUserIssues() {
1120
1864
  return cloneFrozen(this.#viewModel?.issues ?? []);
1121
1865
  }
1122
1866
  async buildOperationReview(intent, returnRoute) {
1867
+ this.#assertAcceptingActions();
1868
+ intent = normalizeOperationIntent(intent);
1123
1869
  try {
1124
1870
  this.#validateReviewIntent(intent);
1125
- const evidence = await this.#prepareReviewEvidence(intent, this.#abort.signal);
1871
+ const evidence = await this.#trackRead(this.#prepareReviewEvidence(intent, this.#abort.signal));
1126
1872
  return this.#storeOperationReview(intent, evidence, 'push', returnRoute);
1127
1873
  }
1128
1874
  catch (error) {
@@ -1134,12 +1880,15 @@ export class TuiController {
1134
1880
  }
1135
1881
  }
1136
1882
  startReviewPreparation(intent, returnRoute) {
1883
+ this.#assertAcceptingActions();
1884
+ intent = normalizeOperationIntent(intent);
1137
1885
  if (intent.kind === 'catalogGeneration' && !intent.sourceMutation) {
1138
- const blockReason = this.catalogActivationBlockReason();
1886
+ const blockReason = this.catalogSourceRecoveryBlockReason();
1139
1887
  if (blockReason) {
1140
1888
  throw new TuiOperationBlockedError('catalogActivationBlocked', `Catalog Active 切换已阻止:${blockReason}。请先完成或放弃当前草稿或操作。`);
1141
1889
  }
1142
1890
  }
1891
+ this.#ensureOperationFlow(intent);
1143
1892
  const inheritedReturnRoute = this.#reviewPreparation?.returnRoute;
1144
1893
  const current = this.#router.snapshot.current.route;
1145
1894
  const navigation = current.name === 'result' || current.name === 'reviewPreparing'
@@ -1151,7 +1900,9 @@ export class TuiController {
1151
1900
  ? this.#result.returnRoute
1152
1901
  : current);
1153
1902
  const normalizedReturnRoute = this.#normalizeRoute(resolvedReturnRoute);
1154
- const key = JSON.stringify(stableValue({ intent, returnRoute: normalizedReturnRoute }));
1903
+ const key = crypto.createHash('sha256')
1904
+ .update(JSON.stringify(stableValue({ intent, returnRoute: normalizedReturnRoute })))
1905
+ .digest('hex');
1155
1906
  if (this.#reviewPreparation?.snapshot.state === 'running'
1156
1907
  && this.#reviewPreparation.key === key) {
1157
1908
  return cloneFrozen(this.#reviewPreparation.snapshot);
@@ -1162,14 +1913,16 @@ export class TuiController {
1162
1913
  const token = ++this.#reviewPreparationToken;
1163
1914
  const snapshot = {
1164
1915
  id,
1165
- intent: structuredClone(intent),
1916
+ operationKind: intent.kind,
1917
+ subject: this.#reviewPreparationLabel(intent),
1166
1918
  state: 'running',
1167
1919
  stages: this.dependencies.prepareReviewEvidence ? [] : [
1168
1920
  { id: 'validate', label: '验证当前状态', state: 'running' },
1169
1921
  { id: 'evidence', label: '准备确认信息', state: 'pending' },
1170
1922
  ],
1171
- currentLabel: this.#reviewPreparationLabel(intent),
1923
+ ...(this.dependencies.prepareReviewEvidence ? {} : { currentStage: 'validate' }),
1172
1924
  };
1925
+ this.#reviewPreparationIntents.set(id, structuredClone(intent));
1173
1926
  this.#reviewPreparation = {
1174
1927
  snapshot,
1175
1928
  key,
@@ -1187,9 +1940,25 @@ export class TuiController {
1187
1940
  const preparation = this.#reviewPreparation;
1188
1941
  if (!preparation || preparation.snapshot.state !== 'failed')
1189
1942
  return undefined;
1190
- return this.startReviewPreparation(preparation.snapshot.intent, preparation.returnRoute);
1943
+ const intent = this.#reviewPreparationIntents.get(preparation.snapshot.id);
1944
+ if (!intent)
1945
+ return undefined;
1946
+ return this.startReviewPreparation(structuredClone(intent), preparation.returnRoute);
1191
1947
  }
1192
1948
  #validateReviewIntent(intent) {
1949
+ if (intent.kind === 'pluginWriteBatch') {
1950
+ if (intent.selection.kind === 'currentProjectIntentRoots'
1951
+ && (intent.action !== 'sync' || intent.scope !== 'project')) {
1952
+ throw new TuiOperationBlockedError('batchSelectionInvalid', '当前项目全部同步只支持 project scope sync。');
1953
+ }
1954
+ if (intent.selection.kind === 'selected') {
1955
+ const canonical = canonicalPluginIds(intent.selection.pluginIds);
1956
+ if (canonical.length === 0) {
1957
+ throw new TuiOperationBlockedError('batchSelectionInvalid', '批量插件选择无效。');
1958
+ }
1959
+ canonical.forEach(sourceIdFromPluginId);
1960
+ }
1961
+ }
1193
1962
  const primaryGeneration = intent.kind === 'sourceMutation' && intent.action === 'setPrimary'
1194
1963
  ? this.#catalogGenerations.get(intent.sourceId) : undefined;
1195
1964
  if (primaryGeneration && !primaryGeneration.active && primaryGeneration.state !== 'ready') {
@@ -1199,6 +1968,11 @@ export class TuiController {
1199
1968
  #reviewPreparationLabel(intent) {
1200
1969
  if (intent.kind === 'catalogSourcesRecovery')
1201
1970
  return 'Catalog 来源配置';
1971
+ if (intent.kind === 'pluginWriteBatch') {
1972
+ return intent.selection.kind === 'selected'
1973
+ ? `${intent.selection.pluginIds.length} 个插件`
1974
+ : '当前项目全部插件';
1975
+ }
1202
1976
  if ('pluginId' in intent) {
1203
1977
  return this.#viewModel?.plugins.find((plugin) => plugin.pluginId === intent.pluginId)?.displayName
1204
1978
  ?? pluginNameFromId(intent.pluginId);
@@ -1217,13 +1991,17 @@ export class TuiController {
1217
1991
  const preparation = this.#reviewPreparation;
1218
1992
  if (!preparation || preparation.token !== token)
1219
1993
  return;
1220
- const intent = structuredClone(preparation.snapshot.intent);
1994
+ const storedIntent = this.#reviewPreparationIntents.get(preparation.snapshot.id);
1995
+ if (!storedIntent)
1996
+ return;
1997
+ const intent = structuredClone(storedIntent);
1221
1998
  const signal = AbortSignal.any([this.#abort.signal, preparation.abort.signal]);
1222
1999
  try {
1223
2000
  this.#validateReviewIntent(intent);
1224
- const evidence = await this.#prepareReviewEvidence(intent, signal, (stage) => {
2001
+ const evidence = await this.#trackRead(this.#prepareReviewEvidence(intent, signal, (stage) => {
1225
2002
  if (!this.#reviewPreparationIsCurrent(token))
1226
2003
  return;
2004
+ preparation.snapshot.currentStage = stage;
1227
2005
  const existing = preparation.snapshot.stages.find((entry) => entry.id === stage);
1228
2006
  preparation.snapshot.stages = preparation.snapshot.stages.map((entry) => entry.state === 'running' ? { ...entry, state: 'completed' } : entry);
1229
2007
  if (existing) {
@@ -1237,12 +2015,13 @@ export class TuiController {
1237
2015
  });
1238
2016
  }
1239
2017
  this.#publish();
1240
- });
2018
+ }));
1241
2019
  if (!this.#reviewPreparationIsCurrent(token))
1242
2020
  return;
1243
2021
  preparation.snapshot.stages = preparation.snapshot.stages.map((stage) => stage.state === 'running' ? { ...stage, state: 'completed' } : stage);
1244
2022
  this.#publish();
1245
2023
  const returnRoute = structuredClone(preparation.returnRoute);
2024
+ this.#reviewPreparationIntents.delete(preparation.snapshot.id);
1246
2025
  this.#reviewPreparation = undefined;
1247
2026
  this.#storeOperationReview(intent, evidence, 'replace', returnRoute);
1248
2027
  }
@@ -1269,7 +2048,10 @@ export class TuiController {
1269
2048
  : code === 'catalogGenerationActiveComparisonUnavailable'
1270
2049
  ? '损坏的当前 Active 无法提供可信版本,不能安全比较候选。当前 Active 保持不变。请明确编辑来源,或移除后重新添加。'
1271
2050
  : code === 'catalogReplaceDamagedRequired'
1272
- ? '无法重新取得或精确匹配旧 Active。当前 Active 保持不变。请返回并选择“使用当前候选替换损坏的 Active”。'
2051
+ ? intent.kind === 'catalogGeneration'
2052
+ && this.#catalogFreshness.get(intent.sourceId)?.state === 'incompatible'
2053
+ ? '无法重新取得或精确匹配旧 Active,且当前候选与此 AW 不兼容。当前 Active 保持不变。请升级 AW 或修改来源。'
2054
+ : '无法重新取得或精确匹配旧 Active。当前 Active 保持不变。请返回并选择“使用当前候选替换损坏的 Active”。'
1273
2055
  : code === 'catalogVersionConflict'
1274
2056
  ? '候选 Catalog 与当前 Active 版本相同,但内容摘要不同。普通更新已阻止。'
1275
2057
  : code === 'catalogRollbackDetected'
@@ -1277,6 +2059,34 @@ export class TuiController {
1277
2059
  : code === 'catalogGenerationUnavailable'
1278
2060
  ? '本次加载时 Catalog 来源不可用。请刷新来源并重新加载后重试。'
1279
2061
  : '无法准备确认信息。请返回检查选择,或重试。';
2062
+ const preparationRecordId = `${this.#sessionEpoch}:${active.snapshot.id}`;
2063
+ const preparationDiagnostic = projectThrownOperationDiagnostic({
2064
+ error,
2065
+ id: `${preparationRecordId}:failure`,
2066
+ stage: active.snapshot.currentStage ?? 'reviewPreparation',
2067
+ fallbackCode: code,
2068
+ sensitiveValues: collectOperationSensitiveValues(intent),
2069
+ });
2070
+ this.#operationHistory.record({
2071
+ summary: {
2072
+ recordId: preparationRecordId,
2073
+ epoch: this.#sessionEpoch,
2074
+ operationId: active.snapshot.id,
2075
+ createdAtMs: this.dependencies.now?.() ?? Date.now(),
2076
+ state: 'blocked',
2077
+ code: preparationDiagnostic.code,
2078
+ message: active.snapshot.message,
2079
+ operationKind: intent.kind,
2080
+ ...('pluginId' in intent ? { pluginId: intent.pluginId } : {}),
2081
+ diagnosticCount: 1,
2082
+ effects: {
2083
+ configCommit: 'notApplicable', awWrite: 'notStarted',
2084
+ preApplyExternalEffect: 'notApplicable', finalVerification: 'notStarted',
2085
+ },
2086
+ },
2087
+ diagnostics: [preparationDiagnostic],
2088
+ targets: [],
2089
+ }, { sensitiveValues: collectOperationSensitiveValues(intent) });
1280
2090
  this.#publish();
1281
2091
  await this.#logOperationError(error, code, intent);
1282
2092
  }
@@ -1300,6 +2110,7 @@ export class TuiController {
1300
2110
  return;
1301
2111
  this.#reviewPreparationToken += 1;
1302
2112
  this.#reviewPreparation.abort.abort();
2113
+ this.#reviewPreparationIntents.delete(this.#reviewPreparation.snapshot.id);
1303
2114
  this.#reviewPreparation = undefined;
1304
2115
  }
1305
2116
  async #logOperationError(error, code, intent) {
@@ -1322,6 +2133,9 @@ export class TuiController {
1322
2133
  }
1323
2134
  }
1324
2135
  #storeOperationReview(intent, evidence, navigation = 'push', returnRoute = this.#router.snapshot.current.route) {
2136
+ this.#ensureOperationFlow(intent);
2137
+ if (intent.kind === 'pluginWriteBatch')
2138
+ assertPluginWriteBatchEvidence(evidence);
1325
2139
  if (this.#router.snapshot.current.route.name === 'result')
1326
2140
  navigation = 'replace';
1327
2141
  const projection = buildOperationReviewProjection({
@@ -1338,7 +2152,10 @@ export class TuiController {
1338
2152
  const expiresAt = now
1339
2153
  + (this.dependencies.reviewTtlMs ?? 2 * 60_000);
1340
2154
  const pluginId = 'pluginId' in intent ? intent.pluginId
1341
- : 'sourceId' in intent ? intent.sourceId : 'Catalog 来源配置';
2155
+ : 'sourceId' in intent ? intent.sourceId
2156
+ : intent.kind === 'pluginWriteBatch'
2157
+ ? `${evidence.requestedPluginIds.length} 个插件`
2158
+ : 'Catalog 来源配置';
1342
2159
  if ('pluginId' in intent)
1343
2160
  sourceIdFromPluginId(intent.pluginId);
1344
2161
  const pluginVersion = 'pluginId' in intent
@@ -1354,6 +2171,7 @@ export class TuiController {
1354
2171
  : intent.kind === 'catalogGeneration' && intent.sourceMutation
1355
2172
  ? { sourceAction: intent.sourceMutation.action } : {}),
1356
2173
  ...(intent.kind === 'pluginWrite' ? { pluginAction: intent.action } : {}),
2174
+ ...(intent.kind === 'pluginWriteBatch' ? { pluginAction: intent.action } : {}),
1357
2175
  ...(intent.kind === 'pluginWrite' && intent.originIssue
1358
2176
  ? { originIssue: intent.originIssue }
1359
2177
  : {}),
@@ -1366,6 +2184,14 @@ export class TuiController {
1366
2184
  ...('pluginId' in intent ? { sourceLabel: sourceIdFromPluginId(intent.pluginId) } : {}),
1367
2185
  ...(pluginVersion ? { version: pluginVersion } : {}),
1368
2186
  },
2187
+ ...(intent.kind === 'pluginWriteBatch' ? {
2188
+ batch: {
2189
+ requestedPluginIds: [...evidence.requestedPluginIds],
2190
+ groupCount: evidence.groups.length,
2191
+ executableGroupCount: evidence.groups
2192
+ .filter((group) => group.executable).length,
2193
+ },
2194
+ } : {}),
1369
2195
  ...(evidence.source && 'catalogVersion' in evidence.source
1370
2196
  && typeof evidence.source.catalogVersion === 'string'
1371
2197
  && evidence.source.snapshotDigest
@@ -1389,6 +2215,14 @@ export class TuiController {
1389
2215
  return cloneFrozen(review);
1390
2216
  }
1391
2217
  executeOperation(reviewToken) {
2218
+ if (this.#retiring || this.#disposed) {
2219
+ return Promise.resolve(cloneFrozen({
2220
+ state: 'blocked',
2221
+ operationId: this.#tickets.get(reviewToken)?.review.operationId ?? 'unknown',
2222
+ code: 'sessionRetiring',
2223
+ message: '会话正在结束,未启动新操作。',
2224
+ }));
2225
+ }
1392
2226
  const inflight = this.#inflightOperations.get(reviewToken);
1393
2227
  if (inflight)
1394
2228
  return inflight.promise;
@@ -1409,6 +2243,7 @@ export class TuiController {
1409
2243
  const promise = this.#executeOperationTicket(ticket, operationId).finally(() => {
1410
2244
  if (this.#inflightOperations.get(reviewToken)?.promise === promise) {
1411
2245
  this.#inflightOperations.delete(reviewToken);
2246
+ this.#publish();
1412
2247
  }
1413
2248
  });
1414
2249
  this.#inflightOperations.set(reviewToken, { operationId, promise });
@@ -1418,8 +2253,10 @@ export class TuiController {
1418
2253
  let generation = this.#generation;
1419
2254
  const startedAtMs = this.dependencies.now?.() ?? Date.now();
1420
2255
  this.#progressFeed?.clearCurrent?.();
1421
- const dependencyPlan = ticket.evidence.dependencyPlan;
1422
- this.#progressTargets = dependencyPlan?.targets.map((target) => ({
2256
+ const dependencyTargets = ticket.intent.kind === 'pluginWriteBatch'
2257
+ ? ticket.evidence.groups.flatMap((group) => group.preview.dependencyPlan?.targets ?? [])
2258
+ : ticket.evidence.dependencyPlan?.targets ?? [];
2259
+ this.#progressTargets = dependencyTargets.map((target) => ({
1423
2260
  pluginId: target.pluginId,
1424
2261
  platform: target.platform,
1425
2262
  scope: target.resolvedScope,
@@ -1428,21 +2265,23 @@ export class TuiController {
1428
2265
  : target.depth === 1 ? 'direct' : 'transitive',
1429
2266
  requiredBy: target.requiredBy,
1430
2267
  state: target.action === 'keep' ? 'kept' : 'pending',
1431
- })) ?? [];
2268
+ }));
1432
2269
  this.#progress = {
1433
2270
  messages: [],
1434
2271
  startedAtMs,
1435
2272
  elapsedMs: 0,
1436
2273
  current: {
1437
- target: 'pluginId' in ticket.intent ? ticket.intent.pluginId
1438
- : 'sourceId' in ticket.intent ? ticket.intent.sourceId : 'Catalog 来源配置',
2274
+ target: ticket.intent.kind === 'pluginWriteBatch'
2275
+ ? `${ticket.evidence.requestedPluginIds.length} 个插件`
2276
+ : 'pluginId' in ticket.intent ? ticket.intent.pluginId
2277
+ : 'sourceId' in ticket.intent ? ticket.intent.sourceId : 'Catalog 来源配置',
1439
2278
  phase: '验证当前状态',
1440
2279
  },
1441
2280
  };
1442
2281
  this.#router.replace({ name: 'progress', operationId });
1443
2282
  this.#publish();
1444
2283
  try {
1445
- if (ticket.intent.kind !== 'pluginWrite') {
2284
+ if (ticket.intent.kind !== 'pluginWrite' && ticket.intent.kind !== 'pluginWriteBatch') {
1446
2285
  await this.dependencies.validateIntent?.(structuredClone(ticket.intent), this.#abort.signal);
1447
2286
  }
1448
2287
  }
@@ -1473,7 +2312,8 @@ export class TuiController {
1473
2312
  }
1474
2313
  if (!this.#operationIsCurrent(generation))
1475
2314
  return this.#detachedBlocked(operationId, ticket.intent);
1476
- if (ticket.intent.kind !== 'pluginWrite' && ticket.intent.kind !== 'catalogGeneration') {
2315
+ if (ticket.intent.kind !== 'pluginWrite' && ticket.intent.kind !== 'pluginWriteBatch'
2316
+ && ticket.intent.kind !== 'catalogGeneration') {
1477
2317
  let current;
1478
2318
  try {
1479
2319
  current = await this.dependencies.captureEvidence(structuredClone(ticket.intent), this.#abort.signal);
@@ -1501,6 +2341,11 @@ export class TuiController {
1501
2341
  const execution = ticket.intent.kind === 'catalogGeneration'
1502
2342
  ? await this.dependencies.executeIntent(structuredClone(ticket.intent), structuredClone(ticket.evidence), this.#abort.signal)
1503
2343
  : await this.dependencies.executeIntent(structuredClone(ticket.intent), structuredClone(ticket.evidence));
2344
+ const executionProjection = projectExecutionDiagnostics(execution, `${this.#sessionEpoch}:${operationId}`, collectOperationSensitiveValues(ticket.intent), ticket.intent.kind);
2345
+ this.#operationDiagnostics.set(operationId, {
2346
+ ...executionProjection,
2347
+ sensitiveValues: collectOperationSensitiveValues(ticket.intent),
2348
+ });
1504
2349
  if (ticket.intent.kind === 'sourceMutation' && ticket.intent.action === 'update') {
1505
2350
  const confirmedAutoCheck = catalogSourceAutoCheckOnlyValue(ticket.intent.update);
1506
2351
  if (confirmedAutoCheck !== undefined) {
@@ -1541,8 +2386,49 @@ export class TuiController {
1541
2386
  await this.#logErrorSafe(error, verificationCode, {
1542
2387
  privateConfigOperation: true,
1543
2388
  });
2389
+ const existingProjection = this.#operationDiagnostics.get(operationId);
2390
+ existingProjection.diagnostics.push(projectThrownOperationDiagnostic({
2391
+ error,
2392
+ id: `${this.#sessionEpoch}:${operationId}:status-reload`,
2393
+ stage: 'statusReload',
2394
+ fallbackCode: verificationCode,
2395
+ }));
2396
+ existingProjection.effects.finalVerification = 'failed';
1544
2397
  if (!this.#operationIsCurrent(generation))
1545
2398
  return this.#detachedBlocked(operationId, ticket.intent);
2399
+ const configCommitted = typeof execution === 'object'
2400
+ && execution !== null
2401
+ && execution.configWrite === 'succeeded';
2402
+ if ((ticket.intent.kind === 'configSave' || ticket.intent.kind === 'configReset')
2403
+ && configCommitted) {
2404
+ const baseSummary = operationResultSummary(ticket.intent, execution);
2405
+ const elapsedMs = Math.max(0, (this.dependencies.now?.() ?? Date.now()) - startedAtMs);
2406
+ const summary = {
2407
+ ...baseSummary,
2408
+ code: 'operationPartial',
2409
+ message: '设置已保存,但最终状态无法刷新。',
2410
+ elapsedMs,
2411
+ messages: [
2412
+ ...this.#progress.messages,
2413
+ ...(baseSummary.messages ?? []),
2414
+ '状态刷新失败。配置写入已经提交;请稍后刷新状态。',
2415
+ ].slice(-8).map((message) => sanitizeDiagnosticText(message, [], collectOperationSensitiveValues(ticket.intent))),
2416
+ };
2417
+ this.#progress = { ...this.#progress, elapsedMs };
2418
+ const result = {
2419
+ state: 'partial',
2420
+ operationId,
2421
+ summary,
2422
+ operationKind: ticket.intent.kind,
2423
+ pluginId: ticket.intent.pluginId,
2424
+ returnRoute: structuredClone(this.#normalizeRoute(ticket.returnRoute)),
2425
+ };
2426
+ this.#result = structuredClone(result);
2427
+ this.#activeReview = undefined;
2428
+ this.#showResult(result, ticket.intent);
2429
+ this.#publish();
2430
+ return cloneFrozen(result);
2431
+ }
1546
2432
  return this.#setBlocked(operationId, verificationCode, {
1547
2433
  intent: ticket.intent,
1548
2434
  navigation: 'result',
@@ -1555,11 +2441,16 @@ export class TuiController {
1555
2441
  if (!this.#operationIsCurrent(generation))
1556
2442
  return this.#detachedBlocked(operationId, ticket.intent);
1557
2443
  this.#applyStatus(refreshed);
2444
+ const verifiedProjection = this.#operationDiagnostics.get(operationId);
2445
+ if (verifiedProjection && verifiedProjection.effects.finalVerification !== 'failed') {
2446
+ verifiedProjection.effects.finalVerification = 'completed';
2447
+ }
1558
2448
  if (ticket.intent.kind === 'catalogSourcesRecovery') {
1559
2449
  this.#phase = 'ready';
1560
2450
  this.#loadError = undefined;
1561
2451
  }
1562
2452
  const pluginSummary = ticket.intent.kind === 'pluginWrite'
2453
+ || ticket.intent.kind === 'pluginWriteBatch'
1563
2454
  ? operationResultSummary(ticket.intent, execution)
1564
2455
  : undefined;
1565
2456
  const hasFailedDependencyTarget = pluginSummary?.targets?.some((target) => target.relation !== undefined && target.relation !== 'requested'
@@ -1570,6 +2461,18 @@ export class TuiController {
1570
2461
  const dependencyFailure = pluginSummary?.code === 'operationFailed'
1571
2462
  && Boolean(hasFailedDependencyTarget);
1572
2463
  if (pluginSummary?.code === 'operationFailed') {
2464
+ const projected = this.#operationDiagnostics.get(operationId);
2465
+ if (projected) {
2466
+ projected.targets = (pluginSummary.targets ?? []).map((target) => ({
2467
+ ...target,
2468
+ diagnosticIds: projected.diagnostics.filter((diagnostic) => diagnostic.target
2469
+ && diagnostic.target.platform === target.platform
2470
+ && diagnostic.target.scope === target.scope
2471
+ && (diagnostic.target.pluginId === undefined || diagnostic.target.pluginId === target.pluginId))
2472
+ .map((diagnostic) => diagnostic.id),
2473
+ effects: projected.targetEffects?.get(`${target.pluginId ?? ''}\0${target.platform}\0${target.scope}`),
2474
+ }));
2475
+ }
1573
2476
  const unsuccessfulTargets = pluginSummary.targets?.filter((target) => target.state === 'failed' || target.state === 'skipped') ?? [];
1574
2477
  const managerFailureCode = unsuccessfulTargets.map((target) => target.errorCode).find((code) => code === 'codexSystemCliUnsupported'
1575
2478
  || code === 'managedCodexAuthorizationRequired'
@@ -1588,8 +2491,10 @@ export class TuiController {
1588
2491
  intent: ticket.intent,
1589
2492
  navigation: 'result',
1590
2493
  returnRoute: ticket.returnRoute,
1591
- ...(dependencyFailure || dependencyStillRequired ? { summary: pluginSummary } : {}),
1592
- ...(!dependencyFailure && !dependencyStillRequired ? { writeMayHaveCompleted: true } : {}),
2494
+ ...(ticket.intent.kind === 'pluginWriteBatch' || dependencyFailure || dependencyStillRequired
2495
+ ? { summary: pluginSummary } : {}),
2496
+ ...(ticket.intent.kind !== 'pluginWriteBatch'
2497
+ && !dependencyFailure && !dependencyStillRequired && ['started', 'partial', 'completed', 'failed', 'unknown'].includes(this.#operationDiagnostics.get(operationId)?.effects.awWrite ?? 'unknown') ? { writeMayHaveCompleted: true } : {}),
1593
2498
  });
1594
2499
  }
1595
2500
  const originIssue = ticket.intent.kind === 'pluginWrite' ? ticket.intent.originIssue : undefined;
@@ -1620,7 +2525,8 @@ export class TuiController {
1620
2525
  const summary = {
1621
2526
  ...baseSummary,
1622
2527
  elapsedMs: Math.max(0, (this.dependencies.now?.() ?? Date.now()) - startedAtMs),
1623
- messages: [...this.#progress.messages, ...(baseSummary.messages ?? [])].slice(-8),
2528
+ messages: [...this.#progress.messages, ...(baseSummary.messages ?? [])].slice(-8)
2529
+ .map((message) => sanitizeDiagnosticText(message, [], collectOperationSensitiveValues(ticket.intent))),
1624
2530
  };
1625
2531
  this.#progress = { ...this.#progress, elapsedMs: summary.elapsedMs };
1626
2532
  if (summary.code === 'operationFailed') {
@@ -1635,7 +2541,7 @@ export class TuiController {
1635
2541
  };
1636
2542
  this.#result = structuredClone(result);
1637
2543
  this.#activeReview = undefined;
1638
- this.#router.replace({ name: 'result', operationId });
2544
+ this.#showResult(result, ticket.intent);
1639
2545
  this.#publish();
1640
2546
  return cloneFrozen(result);
1641
2547
  }
@@ -1666,6 +2572,7 @@ export class TuiController {
1666
2572
  };
1667
2573
  if ((ticket.intent.kind === 'configSave' || ticket.intent.kind === 'configReset')
1668
2574
  && this.#portableRepreparePluginId === ticket.intent.pluginId) {
2575
+ this.#operationDiagnostics.delete(operationId);
1669
2576
  this.#portableRepreparePluginId = undefined;
1670
2577
  this.#activeReview = undefined;
1671
2578
  this.#result = undefined;
@@ -1674,7 +2581,7 @@ export class TuiController {
1674
2581
  }
1675
2582
  this.#result = result;
1676
2583
  this.#activeReview = undefined;
1677
- this.#router.replace({ name: 'result', operationId });
2584
+ this.#showResult(result, ticket.intent);
1678
2585
  this.#publish();
1679
2586
  return cloneFrozen(result);
1680
2587
  }
@@ -1682,44 +2589,106 @@ export class TuiController {
1682
2589
  if (!this.#operationIsCurrent(generation))
1683
2590
  return this.#detachedBlocked(operationId, ticket.intent);
1684
2591
  const code = issueCode(error);
2592
+ const metadata = readSafeOperationError(error);
2593
+ const preApply = metadata?.stage === 'preApply';
2594
+ const configWrite = error && typeof error === 'object' && 'configWrite' in error
2595
+ ? error.configWrite : undefined;
2596
+ this.#captureThrownProjection(operationId, error, code, 'execute', ticket.intent, {
2597
+ configCommit: ticket.intent.kind === 'configSave' || ticket.intent.kind === 'configReset'
2598
+ ? configWrite === 'succeeded' ? 'completed'
2599
+ : configWrite === 'failed' ? 'failed' : 'notStarted' : 'notApplicable',
2600
+ awWrite: preApply ? 'notStarted' : 'unknown',
2601
+ preApplyExternalEffect: preApply ? 'possible' : 'notApplicable',
2602
+ finalVerification: 'notStarted',
2603
+ });
1685
2604
  await this.#logOperationError(error, code, ticket.intent);
1686
2605
  if (!this.#operationIsCurrent(generation))
1687
2606
  return this.#detachedBlocked(operationId, ticket.intent);
1688
2607
  if (code === 'operationReviewStale' || code === 'catalogGenerationReviewStale') {
2608
+ this.#operationDiagnostics.delete(operationId);
1689
2609
  return this.#rebuildStaleReview(ticket, operationId, undefined, generation);
1690
2610
  }
1691
2611
  generation = ++this.#generation;
2612
+ let finalVerification = 'completed';
1692
2613
  try {
1693
2614
  const refreshed = await this.dependencies.loadStatus(this.#abort.signal);
1694
2615
  if (!this.#operationIsCurrent(generation))
1695
2616
  return this.#detachedBlocked(operationId, ticket.intent);
1696
2617
  this.#applyStatus(refreshed);
2618
+ const projection = this.#operationDiagnostics.get(operationId);
2619
+ if (projection)
2620
+ projection.effects.finalVerification = 'completed';
1697
2621
  if (ticket.intent.kind === 'catalogSourcesRecovery'
1698
2622
  && code === 'catalogSourcesConfigRecoveryStale') {
1699
2623
  this.#phase = 'ready';
1700
2624
  this.#loadError = undefined;
1701
- this.#activeReview = undefined;
1702
- this.#result = undefined;
1703
- this.#router.replace({ name: 'home' });
1704
- this.#publish();
1705
- return cloneFrozen({
1706
- state: 'blocked',
1707
- operationId,
1708
- code,
1709
- message: '来源配置已变化,已重新加载当前状态。',
1710
- operationKind: ticket.intent.kind,
2625
+ return this.#setBlocked(operationId, code, {
2626
+ intent: ticket.intent,
2627
+ navigation: 'result',
2628
+ returnRoute: ticket.returnRoute,
2629
+ writeMayHaveCompleted: false,
1711
2630
  });
1712
2631
  }
1713
2632
  }
1714
2633
  catch (reloadError) {
1715
2634
  if (!this.#operationIsCurrent(generation))
1716
2635
  return this.#detachedBlocked(operationId, ticket.intent);
2636
+ finalVerification = 'failed';
2637
+ const projection = this.#operationDiagnostics.get(operationId);
2638
+ if (projection)
2639
+ projection.effects.finalVerification = 'failed';
1717
2640
  await this.#logErrorSafe(reloadError, 'tuiStateReloadFailed', {
1718
2641
  privateConfigOperation: true,
1719
2642
  });
2643
+ if (ticket.intent.kind === 'catalogSourcesRecovery'
2644
+ && code === 'catalogSourcesConfigRecoveryStale') {
2645
+ this.#phase = 'error';
2646
+ this.#loadError = {
2647
+ code: issueCode(reloadError),
2648
+ message: '无法安全读取插件状态。请检查诊断后重试或退出。',
2649
+ };
2650
+ }
1720
2651
  }
1721
2652
  if (!this.#operationIsCurrent(generation))
1722
2653
  return this.#detachedBlocked(operationId, ticket.intent);
2654
+ if ((ticket.intent.kind === 'configSave' || ticket.intent.kind === 'configReset')
2655
+ && configWrite === 'succeeded') {
2656
+ const elapsedMs = Math.max(0, (this.dependencies.now?.() ?? Date.now()) - startedAtMs);
2657
+ const summary = {
2658
+ code: 'operationPartial',
2659
+ message: ticket.intent.kind === 'configReset'
2660
+ ? '设置层重置已提交,但后续步骤未完成。'
2661
+ : finalVerification === 'failed'
2662
+ ? ticket.intent.apply
2663
+ ? '设置已保存,但后续应用或状态复核失败。'
2664
+ : '设置已保存,但状态复核失败。'
2665
+ : ticket.intent.apply
2666
+ ? '设置已保存,但后续应用失败。'
2667
+ : '设置已保存,但后续步骤未完成。',
2668
+ pluginId: ticket.intent.pluginId,
2669
+ configScope: ticket.intent.scope,
2670
+ ...(ticket.intent.kind === 'configSave' && ticket.intent.apply
2671
+ ? { configApplyRetryAvailable: true } : {}),
2672
+ elapsedMs,
2673
+ messages: [ticket.intent.kind === 'configReset'
2674
+ ? '设置层重置已经提交;失败的后续步骤没有恢复旧设置。'
2675
+ : '配置写入已经提交;失败的后续步骤没有回滚设置。'],
2676
+ };
2677
+ this.#progress = { ...this.#progress, elapsedMs };
2678
+ const result = {
2679
+ state: 'partial',
2680
+ operationId,
2681
+ summary,
2682
+ operationKind: ticket.intent.kind,
2683
+ pluginId: ticket.intent.pluginId,
2684
+ returnRoute: structuredClone(this.#normalizeRoute(ticket.returnRoute)),
2685
+ };
2686
+ this.#result = structuredClone(result);
2687
+ this.#activeReview = undefined;
2688
+ this.#showResult(result, ticket.intent);
2689
+ this.#publish();
2690
+ return cloneFrozen(result);
2691
+ }
1723
2692
  return this.#setBlocked(operationId, code, {
1724
2693
  intent: ticket.intent,
1725
2694
  navigation: 'result',
@@ -1732,6 +2701,7 @@ export class TuiController {
1732
2701
  return !this.#disposed && !this.#abort.signal.aborted && generation === this.#generation;
1733
2702
  }
1734
2703
  #detachedBlocked(operationId, intent) {
2704
+ this.#operationDiagnostics.delete(operationId);
1735
2705
  return cloneFrozen({
1736
2706
  state: 'blocked',
1737
2707
  operationId,
@@ -1780,7 +2750,7 @@ export class TuiController {
1780
2750
  };
1781
2751
  this.#result = structuredClone(result);
1782
2752
  this.#activeReview = undefined;
1783
- this.#router.replace({ name: 'result', operationId });
2753
+ this.#showResult(result, ticket.intent);
1784
2754
  this.#publish();
1785
2755
  return cloneFrozen(result);
1786
2756
  }
@@ -1824,6 +2794,9 @@ export class TuiController {
1824
2794
  return cloneFrozen(result);
1825
2795
  }
1826
2796
  #setBlocked(operationId, code, options) {
2797
+ if (!this.#operationDiagnostics.has(operationId)) {
2798
+ this.#captureThrownProjection(operationId, undefined, code, 'execute', options?.intent);
2799
+ }
1827
2800
  const message = code === 'managedCodexAuthorizationRequired'
1828
2801
  ? '操作在插件写入前停止。请在 Review 中确认获取 Codex runtime,或在命令行传入 --allow-managed-codex-install。'
1829
2802
  : code === 'codexSystemCliUnsupported'
@@ -1856,6 +2829,8 @@ export class TuiController {
1856
2829
  this.#router.back();
1857
2830
  this.#normalizeCurrentRoute();
1858
2831
  }
2832
+ else if (options?.intent)
2833
+ this.#showResult(result, options.intent);
1859
2834
  else
1860
2835
  this.#router.replace({ name: 'result', operationId });
1861
2836
  this.#publish();
@@ -1886,7 +2861,7 @@ export class TuiController {
1886
2861
  this.#listeners.clear();
1887
2862
  }
1888
2863
  }
1889
- function operationResultSummary(intent, execution) {
2864
+ function operationResultSummaryUnsafe(intent, execution) {
1890
2865
  if (intent.kind === 'catalogSourcesRecovery') {
1891
2866
  const result = execution;
1892
2867
  return {
@@ -1918,14 +2893,14 @@ function operationResultSummary(intent, execution) {
1918
2893
  : '来源配置已保存,但 Catalog 尚未启用。可在 TUI 中立即重试。'
1919
2894
  : intent.action === 'repair' ? 'Catalog 已重新下载并修复。'
1920
2895
  : intent.action === 'replaceDamaged' ? '损坏的 Active Catalog 已由当前候选替换。'
1921
- : intent.action === 'recoverState' ? 'Catalog generation state 已恢复,目标来源已启用。'
2896
+ : intent.action === 'recoverState' ? '目录版本状态已恢复,目标来源已启用。'
1922
2897
  : updateActivated ? `Catalog 已更新到 ${generation.resolution.catalogVersion}。`
1923
2898
  : intent.action === 'update' ? 'Catalog 已验证,Active 未变化。'
1924
2899
  : 'Catalog 已验证并启用。',
1925
2900
  messages: result.status === 'partial'
1926
2901
  ? [result.partialReason === 'primary'
1927
- ? `新的 Active 可用;主来源保持不变。错误:${result.errorCode ?? 'catalogSourcePrimaryFailed'}`
1928
- : `旧 Active 保持不变。错误:${result.errorCode ?? 'catalogGenerationRefreshFailed'}`]
2902
+ ? `新的已启用版本可用;主来源保持不变。错误:${result.errorCode ?? 'catalogSourcePrimaryFailed'}`
2903
+ : `旧的已启用版本保持不变。错误:${result.errorCode ?? 'catalogGenerationRefreshFailed'}`]
1929
2904
  : updateActivated
1930
2905
  ? ['已安装插件不会自动升级。', '已重新计算可用的插件升级。']
1931
2906
  : intent.action === 'update'
@@ -1963,11 +2938,15 @@ function operationResultSummary(intent, execution) {
1963
2938
  },
1964
2939
  };
1965
2940
  }
1966
- if (intent.kind === 'pluginWrite') {
2941
+ if (intent.kind === 'pluginWrite' || intent.kind === 'pluginWriteBatch') {
1967
2942
  const command = intent.action === 'refreshAndSync' ? 'sync' : intent.action;
1968
2943
  const rawExecution = typeof execution === 'object' && execution !== null
1969
2944
  ? execution
1970
2945
  : {};
2946
+ const requestedPluginIds = intent.kind === 'pluginWriteBatch'
2947
+ ? canonicalPluginIds(execution.requestedPluginIds ?? [])
2948
+ : [intent.pluginId];
2949
+ const fallbackPluginId = requestedPluginIds[0] ?? 'unknown/plugin';
1971
2950
  const normalizedTargetResults = Array.isArray(rawExecution.targetResults)
1972
2951
  ? rawExecution.targetResults.map((candidate) => {
1973
2952
  if (typeof candidate !== 'object' || candidate === null)
@@ -1984,7 +2963,7 @@ function operationResultSummary(intent, execution) {
1984
2963
  ...rawTarget,
1985
2964
  ...(pluginId ? { pluginId } : {}),
1986
2965
  pluginName: typeof rawTarget.pluginName === 'string'
1987
- ? rawTarget.pluginName : pluginNameFromId(pluginId ?? intent.pluginId),
2966
+ ? rawTarget.pluginName : pluginNameFromId(pluginId ?? fallbackPluginId),
1988
2967
  },
1989
2968
  action: typeof entry.action === 'string'
1990
2969
  ? entry.action : requestedIntentAction(command),
@@ -1995,17 +2974,21 @@ function operationResultSummary(intent, execution) {
1995
2974
  ...rawExecution,
1996
2975
  ...(normalizedTargetResults ? { targetResults: normalizedTargetResults } : {}),
1997
2976
  platforms: Array.isArray(rawExecution.platforms) ? rawExecution.platforms : [],
1998
- plugins: Array.isArray(rawExecution.plugins) ? rawExecution.plugins : [intent.pluginId],
2977
+ plugins: Array.isArray(rawExecution.plugins) ? rawExecution.plugins : requestedPluginIds,
1999
2978
  warnings: Array.isArray(rawExecution.warnings) ? rawExecution.warnings : [],
2000
2979
  });
2001
2980
  const result = buildOperationResultModel(receipt);
2002
- const requestedIds = new Set(receipt.requestedPluginIds ?? [intent.pluginId]);
2981
+ const requestedIds = new Set(receipt.requestedPluginIds ?? requestedPluginIds);
2982
+ const retryTargets = intent.kind === 'pluginWriteBatch'
2983
+ ? pluginWriteBatchFailedRequestedTargets(execution)
2984
+ : [];
2985
+ const failedRequestedPluginIds = canonicalPluginIds(retryTargets.map((target) => target.pluginId));
2003
2986
  return {
2004
2987
  code: result.state === 'succeeded' ? 'operationSucceeded'
2005
2988
  : result.state === 'partial' ? 'operationPartial' : 'operationFailed',
2006
2989
  message: result.state === 'succeeded' ? '操作已完成。'
2007
2990
  : result.state === 'partial' ? '操作只完成了一部分。' : '操作结果无法确认。',
2008
- pluginId: intent.pluginId,
2991
+ ...(intent.kind === 'pluginWrite' ? { pluginId: intent.pluginId } : {}),
2009
2992
  action: intent.action,
2010
2993
  changes: result.changes,
2011
2994
  warnings: result.warnings,
@@ -2014,10 +2997,18 @@ function operationResultSummary(intent, execution) {
2014
2997
  messages: [
2015
2998
  ...result.warnings.map((warning) => `警告:${warning}`),
2016
2999
  ...result.recoveryActions.map((action) => `恢复:${action}`),
2017
- ...(intent.recoveryDescription
3000
+ ...(intent.kind === 'pluginWrite' && intent.recoveryDescription
2018
3001
  ? [intent.recoveryDescription, `后续:${intent.scope === 'user' ? '当前项目' : '所有项目'} sync`]
2019
3002
  : []),
2020
3003
  ],
3004
+ ...(intent.kind === 'pluginWriteBatch' ? {
3005
+ batch: {
3006
+ requestedPluginIds,
3007
+ failedRequestedPluginIds,
3008
+ retryTargets,
3009
+ groupCount: execution.groupResults.length,
3010
+ },
3011
+ } : {}),
2021
3012
  targets: result.targetResults.map((entry) => ({
2022
3013
  pluginId: entry.target.pluginId,
2023
3014
  platform: entry.target.platform,
@@ -2029,19 +3020,18 @@ function operationResultSummary(intent, execution) {
2029
3020
  relation: entry.relation === 'requested' ? 'requested'
2030
3021
  : entry.requiredBy.some((pluginId) => requestedIds.has(pluginId)) ? 'direct' : 'transitive',
2031
3022
  requiredBy: entry.requiredBy,
2032
- ...(entry.errorCode === 'dependencyStillRequired'
2033
- || entry.errorCode === 'codexSystemCliUnsupported'
2034
- || entry.errorCode === 'managedCodexAuthorizationRequired'
2035
- || entry.errorCode === 'managedCodexUnavailable'
2036
- ? { errorCode: entry.errorCode }
2037
- : {}),
3023
+ ...(entry.errorCode ? { errorCode: safeDiagnosticCode(entry.errorCode, 'writeFailed') } : {}),
2038
3024
  })),
2039
3025
  };
2040
3026
  }
2041
3027
  if (intent.kind === 'configSave' || intent.kind === 'configReset') {
2042
- const rawTargets = typeof execution === 'object' && execution !== null
2043
- && Array.isArray(execution.targetResults)
2044
- ? execution.targetResults
3028
+ const rawApply = typeof execution === 'object' && execution !== null
3029
+ && typeof execution.apply === 'object'
3030
+ && execution.apply !== null
3031
+ ? execution.apply
3032
+ : {};
3033
+ const rawTargets = Array.isArray(rawApply.targetResults)
3034
+ ? rawApply.targetResults
2045
3035
  : [];
2046
3036
  const targets = rawTargets.flatMap((entry) => {
2047
3037
  const target = typeof entry.target === 'object' && entry.target !== null
@@ -2049,32 +3039,50 @@ function operationResultSummary(intent, execution) {
2049
3039
  : entry;
2050
3040
  if ((target.platform !== 'codex' && target.platform !== 'claude')
2051
3041
  || (target.scope !== 'user' && target.scope !== 'project')
2052
- || (entry.state !== 'completed' && entry.state !== 'failed'))
3042
+ || (entry.state !== 'completed' && entry.state !== 'failed' && entry.state !== 'skipped'))
2053
3043
  return [];
2054
3044
  return [{
2055
3045
  ...(typeof target.pluginId === 'string' ? { pluginId: target.pluginId } : {}),
2056
3046
  platform: target.platform,
2057
3047
  scope: target.scope,
2058
- state: entry.action === 'skipped' ? 'skipped'
3048
+ state: entry.state === 'skipped' || entry.action === 'skipped' ? 'skipped'
2059
3049
  : entry.state === 'failed' ? 'failed'
2060
3050
  : entry.action === 'keep' ? 'kept'
2061
3051
  : entry.state,
3052
+ ...(typeof entry.errorCode === 'string'
3053
+ ? { errorCode: safeDiagnosticCode(entry.errorCode, 'configApplyFailed') }
3054
+ : {}),
2062
3055
  }];
2063
3056
  });
2064
3057
  const overallState = typeof execution === 'object' && execution !== null
2065
3058
  ? execution.overallState
2066
3059
  : undefined;
2067
- const applySkippedReason = typeof execution === 'object' && execution !== null
2068
- ? execution.applySkippedReason
2069
- : undefined;
2070
- const invalidConfig = applySkippedReason === 'invalidConfig';
3060
+ const applyState = rawApply.state;
3061
+ const applyReason = rawApply.reason;
3062
+ const invalidConfig = applyState === 'unavailable' && applyReason === 'invalidConfig';
2071
3063
  const resetDisposition = typeof execution === 'object' && execution !== null
2072
3064
  ? execution.resetDisposition
2073
3065
  : undefined;
2074
3066
  const quarantineBasename = typeof execution === 'object' && execution !== null
2075
3067
  ? execution.quarantineBasename
2076
3068
  : undefined;
3069
+ const publicWarnings = typeof execution === 'object' && execution !== null
3070
+ && Array.isArray(execution.warnings)
3071
+ ? execution.warnings.filter((warning) => typeof warning === 'string'
3072
+ && warning.startsWith('projectConfigShareabilityUnverified:'))
3073
+ : [];
3074
+ const otherProjectsMayRequireSync = intent.scope === 'user'
3075
+ && typeof execution === 'object' && execution !== null
3076
+ && execution.otherProjectsMayRequireSync === true;
3077
+ const publicMessages = [
3078
+ ...publicWarnings.map((warning) => `警告:${warning}`),
3079
+ ...(otherProjectsMayRequireSync
3080
+ ? ['提示:其它项目没有被扫描,可能需要在对应项目目录中运行 aw sync。']
3081
+ : []),
3082
+ ];
2077
3083
  const partial = overallState === 'partial'
3084
+ || applyState === 'partial'
3085
+ || applyState === 'unavailable'
2078
3086
  || invalidConfig
2079
3087
  || targets.some((target) => target.state === 'failed' || target.state === 'skipped')
2080
3088
  && targets.some((target) => target.state === 'completed' || target.state === 'kept');
@@ -2088,9 +3096,10 @@ function operationResultSummary(intent, execution) {
2088
3096
  pluginId: intent.pluginId,
2089
3097
  configScope: intent.scope,
2090
3098
  configApplyRetryAvailable: intent.kind === 'configSave'
2091
- && intent.apply && !invalidConfig && (failed || partial),
3099
+ && intent.apply && !invalidConfig && (applyState === 'partial' || applyState === 'unavailable'),
3100
+ ...(publicMessages.length > 0 ? { messages: publicMessages } : {}),
2092
3101
  ...(intent.kind === 'configSave'
2093
- ? { configApplied: invalidConfig ? false : intent.apply }
3102
+ ? { configApplied: applyState === 'succeeded' || applyState === 'partial' }
2094
3103
  : invalidConfig ? { configApplied: false } : {}),
2095
3104
  ...(invalidConfig ? { configApplySkippedReason: 'invalidConfig' } : {}),
2096
3105
  ...(intent.kind === 'configReset'
@@ -2117,4 +3126,7 @@ function operationResultSummary(intent, execution) {
2117
3126
  ...(sourceWarnings.length > 0 ? { messages: sourceWarnings } : {}),
2118
3127
  };
2119
3128
  }
3129
+ function operationResultSummary(intent, execution) {
3130
+ return sanitizeOperationSummary(operationResultSummaryUnsafe(intent, execution), collectOperationSensitiveValues(intent));
3131
+ }
2120
3132
  //# sourceMappingURL=controller.js.map