dsh-autorestart 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -28,6 +28,20 @@
28
28
  * block it (by title when the Host provided one) and how long this
29
29
  * session has been waiting.
30
30
  *
31
+ * Goal-aware states on top of that:
32
+ *
33
+ * - a blocker whose reason is `goal-active` is a session whose armed goal
34
+ * keeps driving rounds by itself, so it holds the restart on its own.
35
+ * The pill shows that state and offers a button which parks that goal
36
+ * and asks for the restart immediately (`POST <parkUrl>` with
37
+ * `{ sessionId, restart: true }`);
38
+ * - a blocker whose reason is `goal-exhausted` cannot be parked at all
39
+ * (the round budget of its goal is used up), so the pill explains that
40
+ * and offers no button for it;
41
+ * - the Host also reports the goals it already parked (`parkedGoals`): the
42
+ * pill says how many were parked and that they continue automatically
43
+ * after the restart, and the details panel lists them.
44
+ *
31
45
  * Everything here is side-effect free until `apply(ctx)` runs: no timers, no
32
46
  * fetches, no DOM access at module scope.
33
47
  */
@@ -40,6 +54,12 @@ window.__ModuleLoader__.load({
40
54
 
41
55
  const NS = 'dshAutorestart'
42
56
  const HEALTH_URL = '/__dsh_autorestart/health?state=1'
57
+ // The route prefix is derived from the health URL, so the park route is
58
+ // never spelled out twice: the health path without its last segment.
59
+ // The Host sends the authoritative `parkUrl` in the state payload; this
60
+ // is only the fallback for an older Host.
61
+ const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
62
+ const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
43
63
  const POLL_MS = 2000
44
64
  // The brand accent (blue-purple). The pending-restart marker is a brand
45
65
  // signal, not a warning: no warning/yellow token is used here.
@@ -65,13 +85,27 @@ window.__ModuleLoader__.load({
65
85
  busyPrefix: ' · 被 ',
66
86
  busySuffix: ' 个会话占用',
67
87
  idle: ' · 空闲后自动重启',
88
+ goalActive: ' · 被某个会话自动续跑的 goal 占着',
89
+ goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
90
+ parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
91
+ parkButton: '停泊并重启',
92
+ parkButtonAria: '停泊该会话的 goal,并立即请求重启',
93
+ parkBusy: '停泊中…',
94
+ parkError: '停泊失败:{message}',
68
95
  tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
96
+ tooltipWithError: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度。{message}',
69
97
  dotAria: '待重启',
70
98
  panelHeading: '待重启',
71
99
  panelStateBusy: '等待其他会话空闲后重启',
72
100
  panelStateIdle: '等待空闲窗口后重启',
101
+ panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
102
+ panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
73
103
  panelBlockedTitle: '占用会话:{title}',
74
104
  panelBlockedGeneric: '有一个会话正在运行',
105
+ panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
106
+ panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
107
+ panelParkedTitle: '已停泊的 goal(重启后自动继续):',
108
+ panelParkedGoal: '会话 {session} · {goal}',
75
109
  panelWaiting: '已等待 {duration}',
76
110
  panelResume: '重启后本会话会自动继续进度',
77
111
  durationSeconds: '{n} 秒',
@@ -84,14 +118,29 @@ window.__ModuleLoader__.load({
84
118
  busyPrefix: ' · blocked by ',
85
119
  busySuffix: ' session(s)',
86
120
  idle: ' · restarts once idle',
121
+ goalActive: ' · held by a self-continuing goal',
122
+ goalExhausted: ' · a goal ran out of rounds',
123
+ parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
124
+ parkButton: 'Park & restart',
125
+ parkButtonAria: 'Park that session goal and request the restart now',
126
+ parkBusy: 'Parking…',
127
+ parkError: 'Park failed: {message}',
87
128
  tooltip:
88
129
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
130
+ tooltipWithError:
131
+ 'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
89
132
  dotAria: 'Pending restart',
90
133
  panelHeading: 'Pending restart',
91
134
  panelStateBusy: 'Waiting for the other sessions to become idle',
92
135
  panelStateIdle: 'Waiting for the idle window',
136
+ panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
137
+ panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
93
138
  panelBlockedTitle: 'Blocking session: {title}',
94
139
  panelBlockedGeneric: 'One session is running',
140
+ panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
141
+ panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
142
+ panelParkedTitle: 'Parked goals (they continue after the restart):',
143
+ panelParkedGoal: 'session {session} · {goal}',
95
144
  panelWaiting: 'Waiting for {duration}',
96
145
  panelResume: 'This session resumes its progress automatically after the restart.',
97
146
  durationSeconds: '{n}s',
@@ -274,6 +323,24 @@ window.__ModuleLoader__.load({
274
323
  return fill(t('durationHours'), { n: hours, m: minutes % 60 })
275
324
  }
276
325
 
326
+ /**
327
+ * The park route to POST to: the one the Host sent with the state payload,
328
+ * or the one derived from the health path when the Host is older.
329
+ */
330
+ function parkUrlFor(info) {
331
+ const sent = info && typeof info.parkUrl === 'string' ? info.parkUrl.trim() : ''
332
+ if (sent.startsWith('/')) return sent
333
+ return DEFAULT_PARK_URL
334
+ }
335
+
336
+ /** A short, human readable description of a failed request. */
337
+ function describeError(error) {
338
+ if (!error) return 'unknown error'
339
+ if (typeof error === 'string') return error
340
+ if (typeof error.message === 'string' && error.message !== '') return error.message
341
+ return String(error)
342
+ }
343
+
277
344
  // The pill sits in the composer tool row, so it must fit the toolbar
278
345
  // height (~22px) and never push that row around: `flex: 'none'` plus a
279
346
  // fixed height, and the text is clipped with an ellipsis instead of
@@ -380,6 +447,42 @@ window.__ModuleLoader__.load({
380
447
  opacity: 0.75,
381
448
  }
382
449
 
450
+ // The park button lives inside the pill and reuses the pill's own button
451
+ // look (same tokens, same border, same radius), only smaller so the pill
452
+ // stays a one-line chip. It is inline (no absolute/fixed positioning, no
453
+ // negative margins) and never becomes a second element in the slot.
454
+ const PARK_BUTTON_STYLE = {
455
+ ...PILL_STYLE,
456
+ height: 18,
457
+ padding: '0 6px',
458
+ fontSize: 11,
459
+ lineHeight: '16px',
460
+ gap: 4,
461
+ }
462
+
463
+ // The park button while its request is in flight.
464
+ const PARK_BUTTON_BUSY_STYLE = {
465
+ ...PARK_BUTTON_STYLE,
466
+ cursor: 'default',
467
+ opacity: 0.6,
468
+ }
469
+
470
+ // The short "N goals parked" line inside the pill.
471
+ const PARKED_LINE_STYLE = {
472
+ minWidth: 0,
473
+ overflow: 'hidden',
474
+ textOverflow: 'ellipsis',
475
+ whiteSpace: 'nowrap',
476
+ opacity: 0.75,
477
+ }
478
+
479
+ // The inline error line of the details panel.
480
+ const PANEL_ERROR_STYLE = {
481
+ margin: '2px 0',
482
+ fontWeight: 600,
483
+ color: 'var(--dsw-alias-danger, #d14343)',
484
+ }
485
+
383
486
  /**
384
487
  * One compact pill in the composer tool row, visible only while THIS
385
488
  * session is waiting for a pending restart. Clicking it (or pressing
@@ -390,8 +493,13 @@ window.__ModuleLoader__.load({
390
493
  const hasSession = sessionId !== null && sessionId !== ''
391
494
  const info = useSharedInfo(hasSession)
392
495
  const [open, setOpen] = useState(false)
496
+ // The session id of the goal that is currently being parked, or null.
497
+ const [parking, setParking] = useState(null)
498
+ // The message of the last failed park request (the Host's own message).
499
+ const [parkError, setParkError] = useState('')
393
500
  const [, setTick] = useState(0)
394
501
  const wrapperRef = useRef(null)
502
+ const t = translate ?? ((name) => name)
395
503
 
396
504
  const pending = Boolean(info && info.pendingRestart === true)
397
505
  // The restart is imminent: the Host's idle gate is open, so the restart
@@ -400,7 +508,11 @@ window.__ModuleLoader__.load({
400
508
 
401
509
  // The panel never outlives the pending state.
402
510
  useEffect(() => {
403
- if (!pending) setOpen(false)
511
+ if (!pending) {
512
+ setOpen(false)
513
+ setParkError('')
514
+ setParking(null)
515
+ }
404
516
  }, [pending])
405
517
 
406
518
  // Clicking outside the panel or pressing Escape closes it. Both
@@ -440,11 +552,22 @@ window.__ModuleLoader__.load({
440
552
 
441
553
  const blockedBy = Array.isArray(info.blockedBy) ? info.blockedBy : []
442
554
  const others = blockedBy.filter((entry) => entry && String(entry.sessionId) !== key)
443
- const otherBusy = others.length
444
-
445
- const t = translate ?? ((name) => name)
446
- const status =
447
- otherBusy > 0 ? `${t('busyPrefix')}${otherBusy}${t('busySuffix')}` : t('idle')
555
+ // A goal-driven blocker: an armed goal that keeps driving rounds by
556
+ // itself (`goal-active`, parkable) or a goal whose round budget is used
557
+ // up (`goal-exhausted`, not parkable at all).
558
+ const goalActive = others.filter((entry) => String(entry.reason) === 'goal-active')
559
+ const goalExhausted = others.filter((entry) => String(entry.reason) === 'goal-exhausted')
560
+ const otherBusy = others.length - goalActive.length - goalExhausted.length
561
+ // The goals the Host already parked: they continue after the restart.
562
+ const parkedGoals = Array.isArray(info.parkedGoals) ? info.parkedGoals : []
563
+
564
+ const status = goalActive.length > 0
565
+ ? t('goalActive')
566
+ : goalExhausted.length > 0
567
+ ? t('goalExhausted')
568
+ : otherBusy > 0
569
+ ? `${t('busyPrefix')}${otherBusy}${t('busySuffix')}`
570
+ : t('idle')
448
571
 
449
572
  if (pulsing) ensurePulseStyle()
450
573
 
@@ -458,6 +581,57 @@ window.__ModuleLoader__.load({
458
581
  }
459
582
  }
460
583
 
584
+ /**
585
+ * Park the goal of one blocking session and ask for the restart now.
586
+ *
587
+ * The Host parks that goal (so the session stops driving rounds) and
588
+ * makes this session a requester of the pending restart. The shared
589
+ * poll picks the new state up on its next tick.
590
+ *
591
+ * Never throws: a refused request (403) or a conflict (409) shows the
592
+ * Host's own message as an inline error line instead.
593
+ */
594
+ const parkAndRestart = async (targetSessionId) => {
595
+ if (parking) return
596
+ setParking(targetSessionId)
597
+ setParkError('')
598
+ try {
599
+ const response = await fetch(parkUrlFor(info), {
600
+ method: 'POST',
601
+ headers: { 'content-type': 'application/json' },
602
+ body: JSON.stringify({ sessionId: targetSessionId, restart: true }),
603
+ })
604
+ let body = null
605
+ try {
606
+ body = await response.json()
607
+ } catch {
608
+ body = null
609
+ }
610
+ if (!response.ok) {
611
+ const message = body && typeof body === 'object'
612
+ ? (body.error || body.message || '')
613
+ : ''
614
+ setParkError(fill(t('parkError'), { message: message || String(response.status) }))
615
+ setOpen(true)
616
+ return
617
+ }
618
+ if (body && Array.isArray(body.reported)) {
619
+ const reported = body.reported.find(
620
+ (entry) => entry && String(entry.sessionId) === String(targetSessionId),
621
+ )
622
+ if (reported) {
623
+ setParkError(fill(t('parkError'), { message: String(reported.reason || 'not-parked') }))
624
+ setOpen(true)
625
+ }
626
+ }
627
+ } catch (error) {
628
+ setParkError(fill(t('parkError'), { message: describeError(error) }))
629
+ setOpen(true)
630
+ } finally {
631
+ setParking(null)
632
+ }
633
+ }
634
+
461
635
  const pendingSince = stateStore.pendingSince
462
636
  const waitingMs = pendingSince === null || pendingSince === undefined
463
637
  ? 0
@@ -468,17 +642,60 @@ window.__ModuleLoader__.load({
468
642
  h(
469
643
  'div',
470
644
  { key: 'state', style: PANEL_LINE_STYLE },
471
- otherBusy > 0 ? t('panelStateBusy') : t('panelStateIdle'),
645
+ goalActive.length > 0
646
+ ? t('panelStateGoalActive')
647
+ : goalExhausted.length > 0
648
+ ? t('panelStateGoalExhausted')
649
+ : otherBusy > 0
650
+ ? t('panelStateBusy')
651
+ : t('panelStateIdle'),
472
652
  ),
473
653
  ]
654
+ if (parkError) {
655
+ panelLines.push(h('div', { key: 'error', style: PANEL_ERROR_STYLE }, parkError))
656
+ }
474
657
  others.forEach((entry, index) => {
475
658
  const title = entry && typeof entry.title === 'string' ? entry.title.trim() : ''
659
+ const reason = entry && typeof entry.reason === 'string' ? entry.reason : ''
660
+ const name = title || String(entry && entry.sessionId)
661
+ if (reason === 'goal-active') {
662
+ panelLines.push(h(
663
+ 'div',
664
+ { key: 'blocked-' + index, style: PANEL_LINE_STYLE },
665
+ fill(t('panelGoalActiveLine'), { title: name }),
666
+ ))
667
+ return
668
+ }
669
+ if (reason === 'goal-exhausted') {
670
+ panelLines.push(h(
671
+ 'div',
672
+ { key: 'blocked-' + index, style: PANEL_LINE_STYLE },
673
+ fill(t('panelGoalExhaustedLine'), { title: name }),
674
+ ))
675
+ return
676
+ }
476
677
  panelLines.push(h(
477
678
  'div',
478
679
  { key: 'blocked-' + index, style: PANEL_LINE_STYLE },
479
680
  title ? fill(t('panelBlockedTitle'), { title }) : t('panelBlockedGeneric'),
480
681
  ))
481
682
  })
683
+ if (parkedGoals.length > 0) {
684
+ panelLines.push(h(
685
+ 'div',
686
+ { key: 'parked-title', style: PANEL_MUTED_STYLE },
687
+ t('panelParkedTitle'),
688
+ ))
689
+ parkedGoals.forEach((entry, index) => {
690
+ const parkedSession = entry && entry.sessionId != null ? String(entry.sessionId) : ''
691
+ const parkedGoal = entry && entry.goalId != null ? String(entry.goalId) : '-'
692
+ panelLines.push(h(
693
+ 'div',
694
+ { key: 'parked-' + index, style: PANEL_LINE_STYLE },
695
+ fill(t('panelParkedGoal'), { session: parkedSession, goal: parkedGoal }),
696
+ ))
697
+ })
698
+ }
482
699
  panelLines.push(h(
483
700
  'div',
484
701
  { key: 'waiting', style: PANEL_LINE_STYLE },
@@ -502,7 +719,9 @@ window.__ModuleLoader__.load({
502
719
  'div',
503
720
  {
504
721
  style: PILL_STYLE,
505
- title: t('tooltip'),
722
+ title: parkError
723
+ ? fill(t('tooltipWithError'), { message: parkError })
724
+ : t('tooltip'),
506
725
  role: 'button',
507
726
  tabIndex: 0,
508
727
  'aria-haspopup': 'dialog',
@@ -512,6 +731,46 @@ window.__ModuleLoader__.load({
512
731
  },
513
732
  h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
514
733
  h('span', { style: TEXT_STYLE }, h('b', null, t('label')), status),
734
+ // One park button per goal-driven blocker that can be parked. It
735
+ // lives inside the pill, so the slot still has a single root.
736
+ goalActive.map((entry, index) => {
737
+ const target = String(entry.sessionId)
738
+ const busy = parking === target
739
+ const onParkClick = (event) => {
740
+ // The button must not toggle the details panel of the pill.
741
+ if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
742
+ void parkAndRestart(target)
743
+ }
744
+ const onParkKeyDown = (event) => {
745
+ if (!event) return
746
+ const pressed = event.key
747
+ if (pressed === 'Enter' || pressed === ' ' || pressed === 'Spacebar') {
748
+ // Same here: the pill's own Enter / Space handler must not run.
749
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
750
+ }
751
+ }
752
+ return h(
753
+ 'button',
754
+ {
755
+ key: 'park-' + index,
756
+ type: 'button',
757
+ style: busy ? PARK_BUTTON_BUSY_STYLE : PARK_BUTTON_STYLE,
758
+ title: t('parkButtonAria'),
759
+ 'aria-label': t('parkButtonAria'),
760
+ disabled: parking !== null,
761
+ onClick: onParkClick,
762
+ onKeyDown: onParkKeyDown,
763
+ },
764
+ busy ? t('parkBusy') : t('parkButton'),
765
+ )
766
+ }),
767
+ parkedGoals.length > 0
768
+ ? h(
769
+ 'span',
770
+ { style: PARKED_LINE_STYLE },
771
+ fill(t('parkedGoals'), { n: parkedGoals.length }),
772
+ )
773
+ : null,
515
774
  ),
516
775
  open
517
776
  ? h(
package/cordis.patch.yml CHANGED
File without changes
package/icon.svg CHANGED
File without changes