specrails-desktop 2.59.1 → 2.59.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/dist/assets/{ActivityFeedPage-vAY9C71W.js → ActivityFeedPage-BGEayjTY.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-CpwjGXQm.js → AgentBrowserCapture-Qvg4y5Tr.js} +1 -1
- package/client/dist/assets/{AgentModeAnalyticsPane-BHeoJVxQ.js → AgentModeAnalyticsPane-CR7aFeLJ.js} +2 -2
- package/client/dist/assets/{AgentModeCodePane-B0Nf3YkN.js → AgentModeCodePane-gAd88Gng.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-CpbIItY_.js → AgentModeJobsPane-EjCDuTow.js} +1 -1
- package/client/dist/assets/{AgentsPage-BVJAFXn3.js → AgentsPage-CRHHcp1V.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-DDWees-K.js → AnalyticsPage-a0b1fNEl.js} +1 -1
- package/client/dist/assets/{CodePage-CzV5tkvS.js → CodePage-BmV1m7l3.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-DfRHNK7i.js → DesktopAnalyticsPage-B87WmXOQ.js} +1 -1
- package/client/dist/assets/{DocsDialog-BxduBYbh.js → DocsDialog-Co0O672q.js} +1 -1
- package/client/dist/assets/{DocsPage-DOVeRJn_.js → DocsPage-DxGI6A2T.js} +1 -1
- package/client/dist/assets/{ExportDropdown-BnKVV8UK.js → ExportDropdown-1Ce031vB.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-Da9dvM-0.js → InteractiveJobComposer-DkqR77pO.js} +2 -2
- package/client/dist/assets/JobDetailModal-DVLDVgzz.js +1 -0
- package/client/dist/assets/JobDetailPage-RcP9N-D-.js +1 -0
- package/client/dist/assets/{JobsPage-0vdlbNik.js → JobsPage-R9M4xQnp.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-DCUHozXh.js → LoopBuilderPage-C4S-rkWp.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-Df-S8NGf.js → LoopPreviewModal-DA2JeKWX.js} +1 -1
- package/client/dist/assets/{LoopWindowSurface-sdb7QbSo.js → LoopWindowSurface-BkkeD0hx.js} +1 -1
- package/client/dist/assets/{LoopsPage-CDfCC9Px.js → LoopsPage-BHv--iI7.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-DKlPf54Y.js → MinimizedChatsContext-Bc9xHByW.js} +1 -1
- package/client/dist/assets/PluginsPage-BEy62gxK.js +2 -0
- package/client/dist/assets/{ProjectSettingsDialog-DOkjT7xv.js → ProjectSettingsDialog-DDQ_f94T.js} +1 -1
- package/client/dist/assets/{RepositoryDeliveries-CJVpOUpZ.js → RepositoryDeliveries-DIWDFLRM.js} +1 -1
- package/client/dist/assets/{RepositoryScopeSelector-BeM-2gIZ.js → RepositoryScopeSelector-D3PlHIM-.js} +1 -1
- package/client/dist/assets/{ReviewPacketPage-EBCim2w_.js → ReviewPacketPage-BXdPwmdE.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-1-92UGwN.js → TemplatePreviewModal-C7-G6lvI.js} +1 -1
- package/client/dist/assets/{TicketDetailModalContext-CkVXuHxU.js → TicketDetailModalContext-gGUv7gsQ.js} +1 -1
- package/client/dist/assets/{Trans-CzhPjnjB.js → Trans-Br_OIUKP.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-DlQaXc7c.js → formatDistanceToNow-Q7QPRFV4.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-DxUFGcVq.js → getTimezoneOffsetInMilliseconds-Bq0CXBaJ.js} +1 -1
- package/client/dist/assets/{index-Czov72g7.js → index-DVkWHOR3.js} +7 -7
- package/client/dist/assets/jira-B6OYw4CJ.js +1 -0
- package/client/dist/assets/jira-BTF6RaOe.js +1 -0
- package/client/dist/assets/jira-CEetIB2q.js +1 -0
- package/client/dist/assets/jira-CIzsZLw5.js +1 -0
- package/client/dist/assets/{jira-BOoaUXbb.js → jira-Cq6yvN4q.js} +1 -1
- package/client/dist/assets/jira-Daw9tmpI.js +1 -0
- package/client/dist/assets/jira-FXr7u2lh.js +1 -0
- package/client/dist/assets/jira-LaNQn7yl.js +1 -0
- package/client/dist/assets/{jira-api-CbJvbRYx.js → jira-api-C2Tzc362.js} +1 -1
- package/client/dist/assets/{jobs-DZAbh1m2.js → jobs-ARwD85B9.js} +1 -1
- package/client/dist/assets/{jobs-BD47Ov11.js → jobs-BMF1w4WT.js} +1 -1
- package/client/dist/assets/{jobs-tWjcDF_6.js → jobs-CBfzGLwm.js} +1 -1
- package/client/dist/assets/{jobs-CSrRx5lD.js → jobs-CPjiluBY.js} +1 -1
- package/client/dist/assets/{jobs-epP7gt8W.js → jobs-CQQd58pY.js} +1 -1
- package/client/dist/assets/{jobs-1G3wodhl.js → jobs-De3EqaO0.js} +1 -1
- package/client/dist/assets/{jobs-BLOoVRRl.js → jobs-Du9-Ei9a.js} +1 -1
- package/client/dist/assets/{jobs-B4pu8TdF.js → jobs-qMHbnxwC.js} +1 -1
- package/client/dist/assets/{project-repositories-BkBD3rxx.js → project-repositories-DZKmieH3.js} +1 -1
- package/client/dist/assets/{spending-CYotH-9N.js → spending-EXaboRU0.js} +1 -1
- package/client/dist/assets/{useDesktop-DpxVGfUC.js → useDesktop-ChSmmJfc.js} +1 -1
- package/client/dist/assets/{useRuntimeRuns-CfGRbhr9.js → useRuntimeRuns-w9-5OzZl.js} +1 -1
- package/client/dist/assets/{useSharedWebSocket-C17TGqT8.js → useSharedWebSocket-g0nTHfTT.js} +2 -2
- package/client/dist/index.html +16 -16
- package/docs/internals/configuration.md +45 -0
- package/docs/internals/loop-step-log-explorer.md +27 -0
- package/docs/internals/mission-rail-cards.md +7 -0
- package/docs/internals/safe-pr-review-flow.md +9 -0
- package/docs/internals/source-map.md +5 -1
- package/docs/jira-integration-plan.md +7 -4
- package/package.json +1 -1
- package/server/dist/core-execution.js +5 -18
- package/server/dist/jira/jira-db.js +33 -6
- package/server/dist/jira/jira-materializer.js +11 -9
- package/server/dist/jira/jira-status-resolver.js +64 -22
- package/server/dist/jira/jira-sync-manager.js +40 -1
- package/server/dist/jira-router.js +8 -41
- package/server/dist/modules/agent-runtime/runtime/agent-runtime-bridge.js +18 -68
- package/server/dist/modules/agent-runtime/runtime/agent-runtime-controls.js +7 -4
- package/server/dist/modules/agent-runtime/runtime/agent-runtime-verification-log.js +252 -0
- package/server/dist/modules/delivery/runtime/rail-relaunch.js +10 -2
- package/server/dist/modules/loops/runtime/loop-implement-recipe.js +11 -7
- package/server/dist/modules/loops/runtime/loop-run-manager.js +5 -3
- package/server/dist/path-resolver.js +41 -20
- package/server/dist/project-env.js +22 -6
- package/server/dist/util/checkout-path.js +22 -0
- package/server/dist/worktree-node-modules.js +70 -34
- package/client/dist/assets/JobDetailModal-DhwKbZGe.js +0 -1
- package/client/dist/assets/JobDetailPage-MtPE-E_1.js +0 -1
- package/client/dist/assets/PluginsPage-iL0G3rQQ.js +0 -2
- package/client/dist/assets/jira-BNZu8f9O.js +0 -1
- package/client/dist/assets/jira-BYVN4L2u.js +0 -1
- package/client/dist/assets/jira-CKJg3HZd.js +0 -1
- package/client/dist/assets/jira-DDDsZ4ub.js +0 -1
- package/client/dist/assets/jira-DXwYjVE4.js +0 -1
- package/client/dist/assets/jira-Dn6U2nRW.js +0 -1
- package/client/dist/assets/jira-j7hVolR5.js +0 -1
package/client/dist/index.html
CHANGED
|
@@ -204,7 +204,7 @@
|
|
|
204
204
|
.specrails-splash__mark .pill { opacity: 1; }
|
|
205
205
|
}
|
|
206
206
|
</style>
|
|
207
|
-
<script type="module" crossorigin src="/assets/index-
|
|
207
|
+
<script type="module" crossorigin src="/assets/index-DVkWHOR3.js"></script>
|
|
208
208
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
|
|
209
209
|
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-zu2_FqZY.js">
|
|
210
210
|
<link rel="modulepreload" crossorigin href="/assets/react-dom-DiWzBDP9.js">
|
|
@@ -229,8 +229,8 @@
|
|
|
229
229
|
<link rel="modulepreload" crossorigin href="/assets/dashboard-XGdQ5mjw.js">
|
|
230
230
|
<link rel="modulepreload" crossorigin href="/assets/explore-DgBACn39.js">
|
|
231
231
|
<link rel="modulepreload" crossorigin href="/assets/integrations-Dwl9KQp0.js">
|
|
232
|
-
<link rel="modulepreload" crossorigin href="/assets/jira-
|
|
233
|
-
<link rel="modulepreload" crossorigin href="/assets/jobs-
|
|
232
|
+
<link rel="modulepreload" crossorigin href="/assets/jira-FXr7u2lh.js">
|
|
233
|
+
<link rel="modulepreload" crossorigin href="/assets/jobs-De3EqaO0.js">
|
|
234
234
|
<link rel="modulepreload" crossorigin href="/assets/loops--KWr0X-w.js">
|
|
235
235
|
<link rel="modulepreload" crossorigin href="/assets/mcp-CUAN6ORK.js">
|
|
236
236
|
<link rel="modulepreload" crossorigin href="/assets/narration-n3hOA7LY.js">
|
|
@@ -243,15 +243,15 @@
|
|
|
243
243
|
<link rel="modulepreload" crossorigin href="/assets/terminal-BAzpHtpY.js">
|
|
244
244
|
<link rel="modulepreload" crossorigin href="/assets/tickets-D41ZMQpf.js">
|
|
245
245
|
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-Bjbbp_KS.js">
|
|
246
|
-
<link rel="modulepreload" crossorigin href="/assets/useSharedWebSocket-
|
|
247
|
-
<link rel="modulepreload" crossorigin href="/assets/Trans-
|
|
248
|
-
<link rel="modulepreload" crossorigin href="/assets/useDesktop-
|
|
246
|
+
<link rel="modulepreload" crossorigin href="/assets/useSharedWebSocket-g0nTHfTT.js">
|
|
247
|
+
<link rel="modulepreload" crossorigin href="/assets/Trans-Br_OIUKP.js">
|
|
248
|
+
<link rel="modulepreload" crossorigin href="/assets/useDesktop-ChSmmJfc.js">
|
|
249
249
|
<link rel="modulepreload" crossorigin href="/assets/provider-capabilities-lePfeT_Y.js">
|
|
250
250
|
<link rel="modulepreload" crossorigin href="/assets/save-OsUqVn99.js">
|
|
251
|
-
<link rel="modulepreload" crossorigin href="/assets/spending-
|
|
251
|
+
<link rel="modulepreload" crossorigin href="/assets/spending-EXaboRU0.js">
|
|
252
252
|
<link rel="modulepreload" crossorigin href="/assets/sparkles-DrY8hb0X.js">
|
|
253
253
|
<link rel="modulepreload" crossorigin href="/assets/x-CBMI9ejn.js">
|
|
254
|
-
<link rel="modulepreload" crossorigin href="/assets/MinimizedChatsContext-
|
|
254
|
+
<link rel="modulepreload" crossorigin href="/assets/MinimizedChatsContext-Bc9xHByW.js">
|
|
255
255
|
<link rel="modulepreload" crossorigin href="/assets/zap-CUOlvtT1.js">
|
|
256
256
|
<link rel="modulepreload" crossorigin href="/assets/file-text-BcLCfcWZ.js">
|
|
257
257
|
<link rel="modulepreload" crossorigin href="/assets/circle-x-CMYsN4ZH.js">
|
|
@@ -266,20 +266,20 @@
|
|
|
266
266
|
<link rel="modulepreload" crossorigin href="/assets/search-Cq_gE3Ik.js">
|
|
267
267
|
<link rel="modulepreload" crossorigin href="/assets/trash-2-BOU3WdZZ.js">
|
|
268
268
|
<link rel="modulepreload" crossorigin href="/assets/triangle-alert-H9T4jAmh.js">
|
|
269
|
-
<link rel="modulepreload" crossorigin href="/assets/jira-api-
|
|
269
|
+
<link rel="modulepreload" crossorigin href="/assets/jira-api-C2Tzc362.js">
|
|
270
270
|
<link rel="modulepreload" crossorigin href="/assets/clsx-CjueKrWZ.js">
|
|
271
271
|
<link rel="modulepreload" crossorigin href="/assets/utils-BC4DHlz2.js">
|
|
272
272
|
<link rel="modulepreload" crossorigin href="/assets/dialog-CMgps2MY.js">
|
|
273
273
|
<link rel="modulepreload" crossorigin href="/assets/lib-CGdyxIWg.js">
|
|
274
|
-
<link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-
|
|
275
|
-
<link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-
|
|
274
|
+
<link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-Bq0CXBaJ.js">
|
|
275
|
+
<link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-Q7QPRFV4.js">
|
|
276
276
|
<link rel="modulepreload" crossorigin href="/assets/button-D1xExSTF.js">
|
|
277
|
-
<link rel="modulepreload" crossorigin href="/assets/project-repositories-
|
|
278
|
-
<link rel="modulepreload" crossorigin href="/assets/RepositoryScopeSelector-
|
|
279
|
-
<link rel="modulepreload" crossorigin href="/assets/TicketDetailModalContext-
|
|
277
|
+
<link rel="modulepreload" crossorigin href="/assets/project-repositories-DZKmieH3.js">
|
|
278
|
+
<link rel="modulepreload" crossorigin href="/assets/RepositoryScopeSelector-D3PlHIM-.js">
|
|
279
|
+
<link rel="modulepreload" crossorigin href="/assets/TicketDetailModalContext-gGUv7gsQ.js">
|
|
280
280
|
<link rel="modulepreload" crossorigin href="/assets/core-B3mKBIh5.js">
|
|
281
|
-
<link rel="modulepreload" crossorigin href="/assets/RepositoryDeliveries-
|
|
282
|
-
<link rel="modulepreload" crossorigin href="/assets/useRuntimeRuns-
|
|
281
|
+
<link rel="modulepreload" crossorigin href="/assets/RepositoryDeliveries-DIWDFLRM.js">
|
|
282
|
+
<link rel="modulepreload" crossorigin href="/assets/useRuntimeRuns-w9-5OzZl.js">
|
|
283
283
|
<link rel="modulepreload" crossorigin href="/assets/event-Bt7f3wXe.js">
|
|
284
284
|
<link rel="stylesheet" crossorigin href="/assets/index-Bfunvi6w.css">
|
|
285
285
|
</head>
|
|
@@ -82,6 +82,31 @@ Project settings apply to a single project. Open them from the project's **Setti
|
|
|
82
82
|
|
|
83
83
|
---
|
|
84
84
|
|
|
85
|
+
## Jira status mapping and pending updates
|
|
86
|
+
|
|
87
|
+
In the project's Jira connector, map **To Do**, **In Progress**, **On Review**,
|
|
88
|
+
**Done** and **Cancelled** to the real Jira statuses you want to use. Automatic
|
|
89
|
+
uses Jira's status categories; an explicit selection uses the selected status
|
|
90
|
+
identity, even when its category differs. The API accepts status names or status
|
|
91
|
+
IDs, never transition IDs. Both
|
|
92
|
+
outbound transitions and inbound polling honor the mapping. When several logical
|
|
93
|
+
states share a destination, On Review retains precedence; other ambiguities use
|
|
94
|
+
the category-derived state. Prefer distinct destinations when possible.
|
|
95
|
+
|
|
96
|
+
The pending-updates panel identifies the issue and requested destination. After
|
|
97
|
+
correcting a mapping or a Jira workflow restriction, retry the current failed
|
|
98
|
+
operation. A failed transition replaced by a later status intention is retained
|
|
99
|
+
as `superseded` history and removed from attention counts; retrying it cannot
|
|
100
|
+
revert newer work. Comments and field updates remain independently retryable.
|
|
101
|
+
Older pending retries are also suppressed when a newer status transition has
|
|
102
|
+
already completed. Normal pending operations retain their per-issue FIFO order.
|
|
103
|
+
|
|
104
|
+
Jira still determines which transitions the signed-in account can apply. A
|
|
105
|
+
missing reverse transition cannot be bypassed by selecting a target, and the
|
|
106
|
+
connector does not move through arbitrary statuses to discover possible paths.
|
|
107
|
+
If loading the status list fails, the connector preserves the saved mapping and
|
|
108
|
+
offers a retry instead of silently reducing the picker to Automatic.
|
|
109
|
+
|
|
85
110
|
## CLI
|
|
86
111
|
|
|
87
112
|
The `specrails-desktop` CLI is documented in full in [../cli.md](../cli.md). Quick reference:
|
|
@@ -134,6 +159,26 @@ specrails-desktop --port 5000 implement "#42"
|
|
|
134
159
|
|
|
135
160
|
## Environment variables
|
|
136
161
|
|
|
162
|
+
### Project execution environment
|
|
163
|
+
|
|
164
|
+
Project Settings stores the **names** of environment variables to pass through,
|
|
165
|
+
such as `NODE_AUTH_TOKEN`; it never stores their values. One parent project setting
|
|
166
|
+
applies to every repository and workspace admitted to its rails, including isolated
|
|
167
|
+
worktrees. The same policy is applied to retained runtime controls and recovery.
|
|
168
|
+
|
|
169
|
+
Non-empty values already inherited by Desktop take precedence. For missing configured names
|
|
170
|
+
on macOS/Linux, Desktop probes the login shell, using `SHELL` or the account's login
|
|
171
|
+
shell when a GUI launch does not supply it. A zsh login shell reads `~/.zprofile`
|
|
172
|
+
(and interactive configuration); configure the real credential there rather than
|
|
173
|
+
adding it to tracked repository files. Windows uses its inherited environment.
|
|
174
|
+
|
|
175
|
+
Recovered values stay in a project-owned in-memory overlay, not the server's global
|
|
176
|
+
environment, SQLite or runtime snapshots. Each project's lookup cache expires after
|
|
177
|
+
30 seconds, including failed/empty results, so a repaired profile or rotated shell
|
|
178
|
+
credential can be picked up without restarting Desktop. Removing a configured name
|
|
179
|
+
drops its recovered value immediately. A missing value remains absent: Desktop never
|
|
180
|
+
substitutes a dummy credential or treats a blocked package-manager command as passed.
|
|
181
|
+
|
|
137
182
|
A few env vars are read directly by the app. The two most useful operational ones:
|
|
138
183
|
|
|
139
184
|
| Variable | Description |
|
|
@@ -147,6 +147,33 @@ One chip per graph node, states `pending → running → ok / failed / interrupt
|
|
|
147
147
|
- **Filtering** searches every section: while the filter is non-empty, all sections are treated as expanded; clearing it restores the previous mode.
|
|
148
148
|
- **Copy**: each step header carries its own copy button (per-step lines only; the control lives outside the toggle so the header stays a valid button), and the toolbar copy grabs the whole log with `── Step N · title ──` separators.
|
|
149
149
|
|
|
150
|
+
### Verbose verification and retained display
|
|
151
|
+
|
|
152
|
+
Desktop compacts verification output before publishing readable log lines. Passing
|
|
153
|
+
cases and repeated console stacks no longer flood the rail; bounded diagnostic
|
|
154
|
+
context, final test totals and recorded command outcomes remain visible. Omission
|
|
155
|
+
notices distinguish a concise display from the original runtime evidence. This
|
|
156
|
+
changes presentation only, including when tests print warnings or errors but exit
|
|
157
|
+
successfully; recorded exit codes and workflow completion remain authoritative.
|
|
158
|
+
|
|
159
|
+
Both job detail surfaces omit invisible raw `verification-output` events from
|
|
160
|
+
their in-memory display buffer and preserve lifecycle markers when bounding
|
|
161
|
+
ordinary output. A translated notice indicates when earlier readable output was
|
|
162
|
+
removed and that Copy contains only the retained view. Initial history and live
|
|
163
|
+
updates use the same retention rules. Persisted events are unchanged.
|
|
164
|
+
|
|
165
|
+
For detailed verification output, expand **Details → Verification evidence** and
|
|
166
|
+
open a check's stdout or stderr, using the next-page control as needed. These are
|
|
167
|
+
the retained Core artifacts: currently up to 1 MiB per stream, with truncation
|
|
168
|
+
reported explicitly. They follow the configured runtime retention policy and are
|
|
169
|
+
not an unlimited archive. Compaction does not rewrite historical logs in storage.
|
|
170
|
+
|
|
171
|
+
Readable runtime lines preserve an optional recorded `attemptId` in both stored
|
|
172
|
+
log payloads and live WebSocket frames. The existing attempt map uses it to attach
|
|
173
|
+
buffered verification summaries to their originating step, including parallel
|
|
174
|
+
attempts. Older lines without that identity retain their existing arrival-order
|
|
175
|
+
fallback; Desktop does not invent an attempt ID.
|
|
176
|
+
|
|
150
177
|
## Performance
|
|
151
178
|
|
|
152
179
|
- **Collapsed sections render nothing** — the body subtree is not mounted, so a 10k-line settled step costs one header row.
|
|
@@ -48,6 +48,13 @@ with allocation rollback and the original failed checkout retained. A synchronou
|
|
|
48
48
|
click guard prevents duplicate submissions; accepted relaunches stay disabled
|
|
49
49
|
until the new card appears, and rejected admission details remain inline.
|
|
50
50
|
|
|
51
|
+
Workspace narrowing is optional: when a delivery manifest has no explicit
|
|
52
|
+
workspace selections, Relaunch omits `workspaceSelection` and uses registered
|
|
53
|
+
defaults for its recorded repositories. Partial selections remain partial and
|
|
54
|
+
saved explicit selections take precedence; invalid selections still fail normal
|
|
55
|
+
admission. Removing the last repository with a workspace override from a launch
|
|
56
|
+
card also omits the property, instead of sending an invalid empty map.
|
|
57
|
+
|
|
51
58
|
```rail-launch
|
|
52
59
|
{
|
|
53
60
|
"version": 1,
|
|
@@ -124,6 +124,15 @@ Both temporary checkouts use non-force cleanup, including after a Git runner exc
|
|
|
124
124
|
recording the failed replacement id so every client can safely undo its tombstone for that pair.
|
|
125
125
|
- Isolation units remain `per-ticket` or one `all`-scope batch unit. Initial/final SHAs plus commit
|
|
126
126
|
verification distinguish changed, resumed, and no-change results.
|
|
127
|
+
- Warm dependency preparation preserves a registered directory's position inside its Git checkout.
|
|
128
|
+
For example, registering `apps/catalog` prepares its installed packages and Yarn state beneath
|
|
129
|
+
`worktree/apps/catalog/node_modules`. Discovery stays relative to the registered directory
|
|
130
|
+
(depth at most two); unrelated root and sibling installations are not selected. Package entries
|
|
131
|
+
link to the source installation, while tool caches remain local. Existing real installations and
|
|
132
|
+
foreign links are preserved. Live cleanup uses the same projection and exact target fingerprints,
|
|
133
|
+
including proofs for historical misplaced links; symlinked destination ancestors confer no
|
|
134
|
+
authority. Registration paths, frozen manifests and Core scope remain unchanged. A failed run
|
|
135
|
+
keeps its old checkout; after updating Desktop, a fresh Relaunch receives the corrected layout.
|
|
127
136
|
- Per-unit settlement returns structured execution + delivery results. `onLoopRunFinished` receives
|
|
128
137
|
the engine outcome only; commit/status/ref/provenance/push failures cannot rewrite it.
|
|
129
138
|
- Before staging, settlement blocks active OpenSpec change directories introduced relative to
|
|
@@ -5,7 +5,7 @@ architectural dependency declaration. Search a heading or filename and read only
|
|
|
5
5
|
the relevant source and tests. Feature prefixes group the legacy server files;
|
|
6
6
|
new bounded modules live under `server/modules/`.
|
|
7
7
|
|
|
8
|
-
Includes
|
|
8
|
+
Includes 1018 source/build files. Nearby tests are linked where names
|
|
9
9
|
match; integration suites may live elsewhere. Use `rg` to find other consumers.
|
|
10
10
|
|
|
11
11
|
## cli
|
|
@@ -317,6 +317,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
|
|
|
317
317
|
- [DiscardSpecDialog.tsx](../../client/src/features/integrations/components/jira/DiscardSpecDialog.tsx) · [test](../../client/src/features/integrations/components/jira/__tests__/DiscardSpecDialog.test.tsx)
|
|
318
318
|
- [JiraConnectWizard.tsx](../../client/src/features/integrations/components/jira/JiraConnectWizard.tsx) · [test](../../client/src/features/integrations/components/jira/__tests__/JiraConnectWizard.test.tsx)
|
|
319
319
|
- [JiraConnectedCard.tsx](../../client/src/features/integrations/components/jira/JiraConnectedCard.tsx)
|
|
320
|
+
- [JiraOutboxPanel.tsx](../../client/src/features/integrations/components/jira/JiraOutboxPanel.tsx) · [test](../../client/src/features/integrations/components/jira/__tests__/JiraOutboxPanel.test.tsx)
|
|
320
321
|
- [JiraSpecDetailsPanel.tsx](../../client/src/features/integrations/components/jira/JiraSpecDetailsPanel.tsx) · [test](../../client/src/features/integrations/components/jira/__tests__/JiraSpecDetailsPanel.test.tsx)
|
|
321
322
|
|
|
322
323
|
## client/src/features/integrations/context
|
|
@@ -365,6 +366,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
|
|
|
365
366
|
## client/src/features/jobs/lib
|
|
366
367
|
|
|
367
368
|
- [cancel-job.ts](../../client/src/features/jobs/lib/cancel-job.ts) · [test](../../client/src/features/jobs/lib/__tests__/cancel-job.test.ts)
|
|
369
|
+
- [job-event-buffer.ts](../../client/src/features/jobs/lib/job-event-buffer.ts) · [test](../../client/src/features/jobs/lib/__tests__/job-event-buffer.test.ts)
|
|
368
370
|
- [job-log-mode.ts](../../client/src/features/jobs/lib/job-log-mode.ts)
|
|
369
371
|
- [job-time.ts](../../client/src/features/jobs/lib/job-time.ts)
|
|
370
372
|
|
|
@@ -1093,6 +1095,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
|
|
|
1093
1095
|
- [agent-runtime-settings-router.ts](../../server/modules/agent-runtime/runtime/agent-runtime-settings-router.ts)
|
|
1094
1096
|
- [agent-runtime-settings.ts](../../server/modules/agent-runtime/runtime/agent-runtime-settings.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-settings.test.ts)
|
|
1095
1097
|
- [agent-runtime-settlement.ts](../../server/modules/agent-runtime/runtime/agent-runtime-settlement.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-settlement.test.ts)
|
|
1098
|
+
- [agent-runtime-verification-log.ts](../../server/modules/agent-runtime/runtime/agent-runtime-verification-log.ts)
|
|
1096
1099
|
- [agent-runtime-verification-suggestions.ts](../../server/modules/agent-runtime/runtime/agent-runtime-verification-suggestions.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-verification-suggestions.test.ts)
|
|
1097
1100
|
|
|
1098
1101
|
## server/modules/agents/runtime
|
|
@@ -1424,6 +1427,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
|
|
|
1424
1427
|
|
|
1425
1428
|
## server/util
|
|
1426
1429
|
|
|
1430
|
+
- [checkout-path.ts](../../server/util/checkout-path.ts)
|
|
1427
1431
|
- [cli-prompt.ts](../../server/util/cli-prompt.ts) · [test](../../server/util/cli-prompt.test.ts)
|
|
1428
1432
|
- [distribute-int.ts](../../server/util/distribute-int.ts)
|
|
1429
1433
|
- [secure-fs.ts](../../server/util/secure-fs.ts) · [test](../../server/util/secure-fs.test.ts)
|
|
@@ -190,16 +190,18 @@ Por qué token‑paste y no OAuth en v1:
|
|
|
190
190
|
|
|
191
191
|
## 6. Ciclo de vida y mapeo de estados (la parte difícil)
|
|
192
192
|
|
|
193
|
-
Los issues de Jira **no tienen un campo `status` asignable** — hay que **transicionar**, y las transiciones están **gateadas por el workflow** del cliente (que es arbitrario). Las
|
|
193
|
+
Los issues de Jira **no tienen un campo `status` asignable** — hay que **transicionar**, y las transiciones están **gateadas por el workflow** del cliente (que es arbitrario). Las 5 lógicas de Specrails (`todo/in_progress/on_review/done/cancelled`) deben caer sobre N estados de cliente repartidos en solo **3 categorías estables**: `new` / `indeterminate` / `done` (`statusCategory.key`).
|
|
194
194
|
|
|
195
195
|
### Resolver de dos niveles
|
|
196
196
|
|
|
197
197
|
1. **Mapa explícito por proyecto gana siempre.** En Settings el usuario elige, de la **lista real de estados** de su proyecto (fetched en vivo), el target para cada estado lógico. Esto resuelve la ambigüedad (p.ej. dos estados sobre categoría `done`: `Released` vs `Won't Do`).
|
|
198
198
|
2. **Fallback por categoría** cuando no hay mapa: anclar en `statusCategory.key` (nunca en el **nombre** localizable del estado). Para `cancelled`, preferir un *cancel‑lexicon* (`won't do`, `cancelled`, `rejected`, `abandoned`, `invalid`, `duplicate`) y fijar `resolution`; para `done`/éxito, preferir un *ship‑lexicon* (`done`, `closed`, `released`, `resolved`, `complete`) y **alejarse** del cancel‑lexicon.
|
|
199
199
|
|
|
200
|
-
|
|
200
|
+
El mapa también se respeta al materializar Jira en la caché local para los cinco estados lógicos. Si varios estados comparten destino, `on_review` mantiene su prioridad histórica; para otras ambigüedades se conserva la interpretación por categoría.
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
### Camino de transición — recorrido acotado por categorías
|
|
203
|
+
|
|
204
|
+
Como solo ves las transiciones salientes del estado **actual**, un workflow `Backlog → Selected for Dev → In Progress → Done` no ofrece arista directa a `Done`. Algoritmo: `GET /transitions` del issue vivo → aplicar la arista que reduce la distancia a la categoría objetivo (orden `new < indeterminate < done`) → re‑`GET` → repetir. Cap ~5 saltos, dedup de estados visitados (evitar bucles), parar si ninguna transición reduce distancia. **Idempotency‑first**: si hay destino explícito, comparar primero su ID/nombre con el estado actual, incluso si pertenece a otra categoría. Sin destino explícito, la categoría objetivo permite el **no‑op**. Un estado intermedio de la categoría esperada no completa un destino explícito diferente. Este recorrido no explora estados laterales modificando Jira a ciegas. Si no hay camino en N saltos → **dead‑letter** no‑fatal ("mover estado manualmente en Jira") y **jamás** se hace error del rail.
|
|
203
205
|
|
|
204
206
|
### Pantallas de transición / campos requeridos
|
|
205
207
|
|
|
@@ -238,10 +240,11 @@ Diseño de poll:
|
|
|
238
240
|
### Outbound — outbox durable
|
|
239
241
|
|
|
240
242
|
- Drenaje en worker de fondo: **FIFO por‑issue** (una transición debe aterrizar antes que el comentario que la describe), **paralelo entre issues distintos**, con cap de concurrencia.
|
|
241
|
-
- **Idempotencia:** transiciones por
|
|
243
|
+
- **Idempotencia:** transiciones por identidad explícita ID/nombre y, sin mapa, por categoría; **comentarios** con un *self‑marker* invisible embebido en el body ADF (`[specrails:job-<id>]`) — Jira no tiene idempotencia nativa de comentarios, así que antes de re‑postear se hace `GET .../comment` y se salta si el marker ya existe. El marker dobla como filtro de auto‑eco en el poll.
|
|
242
244
|
- **Rate limits:** honrar `Retry-After` en 429 exacto; si ausente, backoff exponencial con jitter (base 2s, cap 30s, ~4 reintentos); respetar el techo ~20 writes/2s por issue. Token‑bucket por debajo de los burst caps.
|
|
243
245
|
- **Clasificación de errores:** `401` = credencial → **pausar** outbox del proyecto + banner re‑auth (no reintentar en bucle); `403` = permiso de operación concreta → dead‑letter nombrando la operación ("tu cuenta no puede transicionar en PROJ"), sin inferir fallo global; `404` sobre issue conocido = terminal (issue borrado/movido) → marcar link `orphaned`, parar la op; solo `429/5xx/timeout` son reintentables.
|
|
244
246
|
- **Dead‑letter visible** con reintento manual: `GET /jira/outbox`, `POST /jira/outbox/:id/retry`, indicador `JiraSyncIndicator` en UI. Un workflow‑gap o un 403 **nunca** es un drop silencioso.
|
|
247
|
+
- **Operaciones reemplazadas:** las transiciones fallidas con una intención posterior para el mismo issue se conservan como `superseded`, fuera del contador de atención y sin reintento. Un reintento antiguo ya encolado tampoco se aplica si una transición posterior ya terminó. Se conservan el FIFO normal y los comentarios/ediciones independientes. El panel identifica el issue y el destino, y muestra errores al cargar los estados con una acción de reintento.
|
|
245
248
|
|
|
246
249
|
---
|
|
247
250
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.checkoutSubdirectory =
|
|
3
|
+
exports.checkoutSubdirectory = void 0;
|
|
4
4
|
exports.prepareCoreExecution = prepareCoreExecution;
|
|
5
5
|
exports.coreVerificationContext = coreVerificationContext;
|
|
6
6
|
exports.checkCoreCompletion = checkCoreCompletion;
|
|
@@ -10,6 +10,9 @@ const node_path_1 = require("node:path");
|
|
|
10
10
|
const core_node_runtime_1 = require("./core-node-runtime");
|
|
11
11
|
const core_update_state_1 = require("./core-update-state");
|
|
12
12
|
const agent_runtime_loader_1 = require("./modules/agent-runtime/runtime/agent-runtime-loader");
|
|
13
|
+
const checkout_path_1 = require("./util/checkout-path");
|
|
14
|
+
var checkout_path_2 = require("./util/checkout-path");
|
|
15
|
+
Object.defineProperty(exports, "checkoutSubdirectory", { enumerable: true, get: function () { return checkout_path_2.checkoutSubdirectory; } });
|
|
13
16
|
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
14
17
|
// Resolve existing ancestors too: the tickets file may not exist yet, and macOS
|
|
15
18
|
// exposes temporary directories through both /var and /private/var.
|
|
@@ -26,22 +29,6 @@ function canonicalPath(path) {
|
|
|
26
29
|
return (0, node_path_1.join)(canonicalPath(parent), (0, node_path_1.basename)(path));
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
|
-
/** Where a directory sits inside its git checkout: '' for the checkout root or a directory outside git. */
|
|
30
|
-
function checkoutSubdirectory(directory) {
|
|
31
|
-
let start;
|
|
32
|
-
try {
|
|
33
|
-
start = (0, node_fs_1.realpathSync)(directory);
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return '';
|
|
37
|
-
}
|
|
38
|
-
for (let current = start;; current = (0, node_path_1.dirname)(current)) {
|
|
39
|
-
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(current, '.git')))
|
|
40
|
-
return (0, node_path_1.relative)(current, start).split(node_path_1.sep).join('/');
|
|
41
|
-
if ((0, node_path_1.dirname)(current) === current)
|
|
42
|
-
return '';
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
32
|
/**
|
|
46
33
|
* A registered repository may be one package of a larger git checkout (a
|
|
47
34
|
* monorepo app). An isolated worktree always mounts the whole checkout, so the
|
|
@@ -54,7 +41,7 @@ function registeredScope(checkout, sourcePath) {
|
|
|
54
41
|
(0, node_fs_1.realpathSync)(sourcePath);
|
|
55
42
|
const local = (0, node_path_1.relative)(checkout, sourcePath).split(node_path_1.sep).join('/');
|
|
56
43
|
const contained = local && local !== '..' && !local.startsWith('../') && !(0, node_path_1.isAbsolute)(local);
|
|
57
|
-
const prefix = checkoutSubdirectory(checkout) ? (contained ? local : '') : checkoutSubdirectory(sourcePath) || (contained ? local : '');
|
|
44
|
+
const prefix = (0, checkout_path_1.checkoutSubdirectory)(checkout) ? (contained ? local : '') : (0, checkout_path_1.checkoutSubdirectory)(sourcePath) || (contained ? local : '');
|
|
58
45
|
if (!prefix)
|
|
59
46
|
return undefined;
|
|
60
47
|
if (!(0, node_fs_1.statSync)((0, node_path_1.join)(checkout, ...prefix.split('/'))).isDirectory())
|
|
@@ -33,6 +33,7 @@ exports.markOutboxDone = markOutboxDone;
|
|
|
33
33
|
exports.markOutboxRetry = markOutboxRetry;
|
|
34
34
|
exports.markOutboxDead = markOutboxDead;
|
|
35
35
|
exports.resetInflight = resetInflight;
|
|
36
|
+
exports.reconcileSupersededTransitions = reconcileSupersededTransitions;
|
|
36
37
|
exports.retryDeadOutbox = retryDeadOutbox;
|
|
37
38
|
exports.listOutbox = listOutbox;
|
|
38
39
|
exports.countOutboxByState = countOutboxByState;
|
|
@@ -328,12 +329,38 @@ function resetInflight(db) {
|
|
|
328
329
|
.run(new Date().toISOString());
|
|
329
330
|
return info.changes;
|
|
330
331
|
}
|
|
331
|
-
/**
|
|
332
|
+
/**
|
|
333
|
+
* Preserve obsolete failed status intentions as terminal history rather than
|
|
334
|
+
* offering a retry that could revert a newer intention. Ordinary pending and
|
|
335
|
+
* inflight operations retain FIFO ownership. A pending row with a newer DONE
|
|
336
|
+
* transition is an old retry (normal FIFO cannot produce that ordering).
|
|
337
|
+
* Comments and field edits are independent operations and never superseded.
|
|
338
|
+
*/
|
|
339
|
+
function reconcileSupersededTransitions(db) {
|
|
340
|
+
return db.prepare(`UPDATE jira_outbox AS candidate
|
|
341
|
+
SET state = 'superseded', next_attempt_at = NULL, updated_at = ?
|
|
342
|
+
WHERE candidate.op_type = 'transition'
|
|
343
|
+
AND candidate.state IN ('dead', 'pending')
|
|
344
|
+
AND EXISTS (
|
|
345
|
+
SELECT 1 FROM jira_outbox AS newer
|
|
346
|
+
WHERE newer.jira_issue_id = candidate.jira_issue_id
|
|
347
|
+
AND newer.op_type = 'transition'
|
|
348
|
+
AND newer.id > candidate.id
|
|
349
|
+
AND (candidate.state = 'dead' OR newer.state = 'done')
|
|
350
|
+
)`).run(new Date().toISOString()).changes;
|
|
351
|
+
}
|
|
352
|
+
/** Reconcile and re-queue atomically, so an obsolete intention is never revived. */
|
|
332
353
|
function retryDeadOutbox(db, id) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
.
|
|
336
|
-
|
|
354
|
+
return db.transaction(() => {
|
|
355
|
+
reconcileSupersededTransitions(db);
|
|
356
|
+
const row = db.prepare('SELECT state FROM jira_outbox WHERE id = ?').get(id);
|
|
357
|
+
if (row?.state === 'superseded')
|
|
358
|
+
return 'superseded';
|
|
359
|
+
if (row?.state !== 'dead')
|
|
360
|
+
return null;
|
|
361
|
+
db.prepare("UPDATE jira_outbox SET state = 'pending', next_attempt_at = NULL, dead_reason = NULL, last_error = NULL, updated_at = ? WHERE id = ? AND state = 'dead'").run(new Date().toISOString(), id);
|
|
362
|
+
return 'pending';
|
|
363
|
+
})();
|
|
337
364
|
}
|
|
338
365
|
function listOutbox(db, opts = {}) {
|
|
339
366
|
const limit = Math.min(opts.limit ?? 200, 1000);
|
|
@@ -344,7 +371,7 @@ function listOutbox(db, opts = {}) {
|
|
|
344
371
|
}
|
|
345
372
|
function countOutboxByState(db) {
|
|
346
373
|
const rows = db.prepare('SELECT state, COUNT(*) AS n FROM jira_outbox GROUP BY state').all();
|
|
347
|
-
const out = { pending: 0, inflight: 0, done: 0, dead: 0 };
|
|
374
|
+
const out = { pending: 0, inflight: 0, done: 0, dead: 0, superseded: 0 };
|
|
348
375
|
for (const r of rows) {
|
|
349
376
|
if (r.state in out)
|
|
350
377
|
out[r.state] = r.n;
|
|
@@ -37,19 +37,21 @@ function issueStatusCategory(issue) {
|
|
|
37
37
|
return k === 'new' || k === 'done' ? k : 'indeterminate';
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* category fallback
|
|
44
|
-
* a ticket parked at on_review once its outbox transition drains (the frozen
|
|
45
|
-
* guard only protects the pending window). Unconfigured ⇒ byte-identical
|
|
46
|
-
* category mapping.
|
|
40
|
+
* Explicit status identities apply in both sync directions, even when a
|
|
41
|
+
* customer's category differs from the logical state's default. Preserve the
|
|
42
|
+
* historical on_review precedence for shared mappings; other ambiguous targets
|
|
43
|
+
* retain category fallback rather than choosing by configuration object order.
|
|
47
44
|
*/
|
|
48
45
|
function mapStatus(issue, statusMap) {
|
|
49
46
|
const name = (issue.fields.status?.name ?? '').toLowerCase();
|
|
50
|
-
const
|
|
51
|
-
|
|
47
|
+
const matches = (target) => Boolean(target
|
|
48
|
+
&& (issue.fields.status?.id === target || (name && name === target.toLowerCase())));
|
|
49
|
+
if (matches(statusMap?.on_review))
|
|
52
50
|
return 'on_review';
|
|
51
|
+
const states = ['todo', 'in_progress', 'done', 'cancelled'];
|
|
52
|
+
const mapped = states.filter((state) => matches(statusMap?.[state]));
|
|
53
|
+
if (mapped.length === 1)
|
|
54
|
+
return mapped[0];
|
|
53
55
|
const cat = issueStatusCategory(issue);
|
|
54
56
|
if (cat === 'new')
|
|
55
57
|
return 'todo';
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// 2. Category fallback anchored on `statusCategory.key` (never the localizable
|
|
10
10
|
// status NAME), with a cancel/ship lexicon to disambiguate the `done`
|
|
11
11
|
// category (e.g. `Won't Do` vs `Released`).
|
|
12
|
-
// 3.
|
|
13
|
-
//
|
|
12
|
+
// 3. Bounded category-monotonic transition walk where there is no direct edge
|
|
13
|
+
// to the target category. Unknown lateral paths are never explored by writes.
|
|
14
14
|
//
|
|
15
15
|
// This module is PURE: the walker takes async callbacks (getTransitions /
|
|
16
16
|
// applyTransition) so it is fully testable without HTTP.
|
|
@@ -60,12 +60,14 @@ function nameMatches(name, lexicon) {
|
|
|
60
60
|
function isCancelName(name) {
|
|
61
61
|
return nameMatches(name, CANCEL_LEXICON);
|
|
62
62
|
}
|
|
63
|
+
function matchesStatusTarget(id, name, target) {
|
|
64
|
+
return id === target || (name !== undefined && name.toLowerCase() === target.toLowerCase());
|
|
65
|
+
}
|
|
63
66
|
/** Does this transition's destination match the user's explicitly configured
|
|
64
|
-
* target (by status id
|
|
67
|
+
* target (by status id or status name)? Case-insensitive on name.
|
|
65
68
|
* The explicit config ALWAYS wins over the cancel/ship lexicon heuristics. */
|
|
66
69
|
function matchesExplicit(t, explicitTarget) {
|
|
67
|
-
|
|
68
|
-
return t.to.id === explicitTarget || t.to.name.toLowerCase() === e || t.id === explicitTarget;
|
|
70
|
+
return matchesStatusTarget(t.to.id, t.to.name, explicitTarget);
|
|
69
71
|
}
|
|
70
72
|
function transitionCategory(t) {
|
|
71
73
|
const k = t.to.statusCategory?.key;
|
|
@@ -81,6 +83,8 @@ function transitionCategory(t) {
|
|
|
81
83
|
*/
|
|
82
84
|
function pickDirectTransition(transitions, state, explicitTarget) {
|
|
83
85
|
if (explicitTarget) {
|
|
86
|
+
// Values identify destination statuses, never transition IDs: the two ID
|
|
87
|
+
// namespaces can collide, including with an unrelated terminal transition.
|
|
84
88
|
const t = transitions.find((tr) => matchesExplicit(tr, explicitTarget));
|
|
85
89
|
if (t)
|
|
86
90
|
return t;
|
|
@@ -117,9 +121,9 @@ function pickDirectTransition(transitions, state, explicitTarget) {
|
|
|
117
121
|
}
|
|
118
122
|
/**
|
|
119
123
|
* Pick a transition that moves the issue closer to the target category (used by
|
|
120
|
-
* the
|
|
124
|
+
* the bounded walk when no direct transition exists). Returns the edge whose target
|
|
121
125
|
* category is strictly closer (in rank distance) to the goal, never overshooting
|
|
122
|
-
* past it, preferring the
|
|
126
|
+
* past it, preferring the furthest progress in either direction.
|
|
123
127
|
*/
|
|
124
128
|
function pickProgressTransition(transitions, currentCategory, targetCategory, visitedStatusIds, opts) {
|
|
125
129
|
const goal = categoryRank(targetCategory);
|
|
@@ -189,26 +193,57 @@ function buildTransitionFields(transition, state) {
|
|
|
189
193
|
}
|
|
190
194
|
return Object.keys(out).length > 0 ? { fields: out } : {};
|
|
191
195
|
}
|
|
196
|
+
function diagnosticValue(value, limit = 64) {
|
|
197
|
+
const compact = value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
|
198
|
+
if (compact.length <= limit && JSON.stringify(compact).length <= limit)
|
|
199
|
+
return JSON.stringify(compact);
|
|
200
|
+
let preview = compact.slice(0, limit);
|
|
201
|
+
while (JSON.stringify(`${preview}…`).length > limit)
|
|
202
|
+
preview = preview.slice(0, -1);
|
|
203
|
+
return JSON.stringify(`${preview}…`);
|
|
204
|
+
}
|
|
205
|
+
function statusDiagnostic(name, id) {
|
|
206
|
+
return `${name ? diagnosticValue(name, 48) : '(unknown)'}${id ? ` (id ${diagnosticValue(id, 24)})` : ''}`;
|
|
207
|
+
}
|
|
208
|
+
function noPathReason(summary, currentName, currentId, explicitTarget, transitions) {
|
|
209
|
+
// Dead-letter storage retains at most 500 characters. Budget the entire
|
|
210
|
+
// message here so available destinations are never silently cut off there.
|
|
211
|
+
const prefix = `${summary.slice(0, 160)}; current status ${statusDiagnostic(currentName, currentId)}`
|
|
212
|
+
+ (explicitTarget ? `; configured target ${diagnosticValue(explicitTarget)}` : '')
|
|
213
|
+
+ '; available destinations: ';
|
|
214
|
+
if (transitions === undefined)
|
|
215
|
+
return `${prefix}not fetched for current status`;
|
|
216
|
+
const available = [];
|
|
217
|
+
for (const transition of transitions.slice(0, 8)) {
|
|
218
|
+
const candidate = [...available, statusDiagnostic(transition.to.name, transition.to.id)];
|
|
219
|
+
const omitted = transitions.length - candidate.length;
|
|
220
|
+
const suffix = omitted > 0 ? `; +${omitted} more` : '';
|
|
221
|
+
if (`${prefix}${candidate.join(', ')}${suffix}`.length > 500)
|
|
222
|
+
break;
|
|
223
|
+
available.push(candidate[candidate.length - 1]);
|
|
224
|
+
}
|
|
225
|
+
const omitted = transitions.length - available.length;
|
|
226
|
+
return `${prefix}${available.join(', ') || (omitted ? '(omitted)' : 'none')}${omitted > 0 ? `; +${omitted} more` : ''}`;
|
|
227
|
+
}
|
|
192
228
|
/**
|
|
193
229
|
* Walk the transition graph from the current category to the target category for
|
|
194
230
|
* `state`, applying edges per hop (you can only see the current status's
|
|
195
|
-
* outgoing edges).
|
|
196
|
-
*
|
|
197
|
-
* sitting on it (see `currentStatusName`).
|
|
231
|
+
* outgoing edges). An explicit destination's identity wins over the default
|
|
232
|
+
* category, including idempotency. Without a destination, the category suffices.
|
|
198
233
|
*/
|
|
199
234
|
async function walkToCategory(args) {
|
|
200
235
|
const target = targetCategoryFor(args.state);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return { status: 'noop' };
|
|
207
|
-
}
|
|
236
|
+
const alreadyAtTarget = args.explicitTarget
|
|
237
|
+
? matchesStatusTarget(args.currentStatusId, args.currentStatusName, args.explicitTarget)
|
|
238
|
+
: args.currentCategory === target;
|
|
239
|
+
if (alreadyAtTarget)
|
|
240
|
+
return { status: 'noop' };
|
|
208
241
|
const maxHops = args.maxHops ?? 5;
|
|
209
|
-
const visited = new Set();
|
|
242
|
+
const visited = new Set(args.currentStatusId ? [args.currentStatusId] : []);
|
|
210
243
|
const applied = [];
|
|
211
244
|
let currentCategory = args.currentCategory;
|
|
245
|
+
let currentStatusName = args.currentStatusName;
|
|
246
|
+
let currentStatusId = args.currentStatusId;
|
|
212
247
|
for (let hop = 0; hop < maxHops; hop++) {
|
|
213
248
|
let transitions;
|
|
214
249
|
try {
|
|
@@ -230,7 +265,7 @@ async function walkToCategory(args) {
|
|
|
230
265
|
return { status: 'error', reason: err instanceof Error ? err.message : String(err) };
|
|
231
266
|
}
|
|
232
267
|
applied.push(direct.id);
|
|
233
|
-
return { status: 'applied', finalCategory:
|
|
268
|
+
return { status: 'applied', finalCategory: transitionCategory(direct), transitions: applied };
|
|
234
269
|
}
|
|
235
270
|
// No direct edge → step toward the target category. For any non-cancel walk
|
|
236
271
|
// the step must avoid reject statuses so the issue never overshoots onto a
|
|
@@ -242,7 +277,7 @@ async function walkToCategory(args) {
|
|
|
242
277
|
if (!step) {
|
|
243
278
|
return {
|
|
244
279
|
status: 'no_path',
|
|
245
|
-
reason: `no workflow transition from category "${currentCategory}" toward "${target}"`,
|
|
280
|
+
reason: noPathReason(`no workflow transition from category "${currentCategory}" toward "${target}"`, currentStatusName, currentStatusId, args.explicitTarget, transitions),
|
|
246
281
|
};
|
|
247
282
|
}
|
|
248
283
|
const plan = buildTransitionFields(step, args.state);
|
|
@@ -256,12 +291,19 @@ async function walkToCategory(args) {
|
|
|
256
291
|
}
|
|
257
292
|
applied.push(step.id);
|
|
258
293
|
visited.add(step.to.id);
|
|
294
|
+
currentStatusName = step.to.name;
|
|
295
|
+
currentStatusId = step.to.id;
|
|
259
296
|
const stepCat = transitionCategory(step);
|
|
260
297
|
if (stepCat)
|
|
261
298
|
currentCategory = stepCat;
|
|
262
|
-
if (currentCategory === target) {
|
|
299
|
+
if (!args.explicitTarget && currentCategory === target) {
|
|
263
300
|
return { status: 'applied', finalCategory: target, transitions: applied };
|
|
264
301
|
}
|
|
265
302
|
}
|
|
266
|
-
return {
|
|
303
|
+
return {
|
|
304
|
+
status: 'no_path',
|
|
305
|
+
reason: noPathReason(args.explicitTarget
|
|
306
|
+
? `configured status not reached within ${maxHops} hops`
|
|
307
|
+
: `target category "${target}" not reached within ${maxHops} hops`, currentStatusName, currentStatusId, args.explicitTarget, undefined),
|
|
308
|
+
};
|
|
267
309
|
}
|