@linxin666/dsh-client-ui-task-board 0.3.23 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +275 -48
- package/lib/client.js.map +1 -1
- package/lib/index.js +65 -18
- package/lib/types/client/board/TaskForm.d.ts +6 -1
- package/lib/types/client/board/parse-model-pref.d.ts +4 -0
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/plugin-card-seat.d.ts +96 -0
- package/lib/types/client/sidebar-entry-core.d.ts +32 -0
- package/lib/types/core/session-reuse.d.ts +4 -2
- package/lib/types/host-ledger.d.ts +15 -0
- package/package.json +2 -1
- package/src/client/board/NewTaskModal.tsx +33 -7
- package/src/client/board/TaskForm.tsx +13 -0
- package/src/client/board/parse-model-pref.ts +35 -0
- package/src/client/index.ts +11 -17
- package/src/client/locales.ts +2 -0
- package/src/client/plugin-card-seat.ts +185 -0
- package/src/client/sidebar-entry-core.ts +96 -12
- package/src/core/session-reuse.ts +14 -5
- package/src/host-ledger.ts +59 -11
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/plugin-card-seat.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Family plugin-card seat.
|
|
4
|
+
*
|
|
5
|
+
* A family plugin contributes its settings card to whichever plugin-card seat
|
|
6
|
+
* the running host actually renders:
|
|
7
|
+
*
|
|
8
|
+
* - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
|
|
9
|
+
* group section (this family's own first-level "Web UI plugins" section);
|
|
10
|
+
* - `settings.plugin.item` — the official keyed seat of the harness's
|
|
11
|
+
* `ui-settings-plugins` tab, keyed by the settings namespace the card edits.
|
|
12
|
+
*
|
|
13
|
+
* SEAT SELECTION IS NOT A DECLARATION PROBE. The official `ui-settings-plugins`
|
|
14
|
+
* row belongs to the harness bundle and its `configurable` tab always declares
|
|
15
|
+
* `settings.plugin.item` before any external plugin's `apply()` runs, so
|
|
16
|
+
* "is the official seat declared?" answers yes even in the deployment whose
|
|
17
|
+
* whole point is the family group. Choosing on that probe sends every family
|
|
18
|
+
* card to the official Plugins tab and leaves the group's own section
|
|
19
|
+
* permanently empty — the family of reports where the section renders its
|
|
20
|
+
* heading and zero cards.
|
|
21
|
+
*
|
|
22
|
+
* The signal that actually distinguishes the two deployments is whether
|
|
23
|
+
* dsh-web-settings is loaded: it is the package that owns the group section and
|
|
24
|
+
* it publishes the `webUiSettings` service during `apply()`, which every
|
|
25
|
+
* family plugin already reads for its settings scope. Group loaded -> the family
|
|
26
|
+
* seat; group absent -> the official seat.
|
|
27
|
+
*
|
|
28
|
+
* The decision is re-evaluated on every `slots/changed` because the group may
|
|
29
|
+
* apply after this plugin (the family aggregate orders it first, a profile that
|
|
30
|
+
* installs the group separately need not): the initial contribution goes to the
|
|
31
|
+
* official seat, then moves to the family seat the moment the group's section
|
|
32
|
+
* registers. The entry is disposed before the replacement is registered, so a
|
|
33
|
+
* card is never in two seats at once.
|
|
34
|
+
*
|
|
35
|
+
* The shared tree has no client-SDK dependency, so this module reads its
|
|
36
|
+
* context through the structural shape below; callers pass the plugin's own
|
|
37
|
+
* `ctx`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The family list seat key. */
|
|
41
|
+
export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item'
|
|
42
|
+
|
|
43
|
+
/** The official keyed plugin-card seat key. */
|
|
44
|
+
export const OFFICIAL_PLUGIN_CARD_SEAT = 'settings.plugin.item'
|
|
45
|
+
|
|
46
|
+
/** The service dsh-web-settings publishes while it is loaded. */
|
|
47
|
+
export const FAMILY_GROUP_SERVICE = 'webUiSettings'
|
|
48
|
+
|
|
49
|
+
/** The slot-registry member this helper uses (structurally satisfied by ctx.slots). */
|
|
50
|
+
export interface PluginCardSlots {
|
|
51
|
+
/** Contribution of one card entry. */
|
|
52
|
+
register(options: never, component: never): unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The slice of the client context a card contribution needs. */
|
|
56
|
+
export interface PluginCardContext {
|
|
57
|
+
slots: PluginCardSlots
|
|
58
|
+
/** Service lookup; absent on a context double that only models the slots. */
|
|
59
|
+
get?(name: string): unknown
|
|
60
|
+
/** Event subscription seat; absent on an event-less test double. */
|
|
61
|
+
on?(event: string, listener: (...args: never[]) => void): unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Owner share of a plugin card (both seats supply nothing). */
|
|
65
|
+
export interface SettingsPluginItemOwnerProps {
|
|
66
|
+
/** Marker field: card owner props are intentionally empty. */
|
|
67
|
+
children?: never
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One family plugin's card contribution. */
|
|
71
|
+
export interface PluginCardSeat {
|
|
72
|
+
/** Settings namespace the card edits (the official seat's dispatch key). */
|
|
73
|
+
namespace: string
|
|
74
|
+
/** Family list-seat entry id. */
|
|
75
|
+
id: string
|
|
76
|
+
/** Family list-seat sort order. */
|
|
77
|
+
order?: number
|
|
78
|
+
/** Family list-seat display label; the official keyed seat carries none. */
|
|
79
|
+
label?: () => string
|
|
80
|
+
/** Locale namespace the card renders with. */
|
|
81
|
+
locale: string
|
|
82
|
+
/** Business-face factory of the registration. */
|
|
83
|
+
inject?: () => object
|
|
84
|
+
/**
|
|
85
|
+
* The card component. Its props type is the seat's composed shape, which is
|
|
86
|
+
* chosen at runtime, so the helper takes it erased; the registrant keeps its
|
|
87
|
+
* own precise typing at the call site.
|
|
88
|
+
*/
|
|
89
|
+
component: unknown
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether the family group (dsh-web-settings) is loaded in this page. The
|
|
94
|
+
* service is the group package's own contract, so the probe cannot be fooled
|
|
95
|
+
* by a harness release that starts declaring the official seat differently.
|
|
96
|
+
*/
|
|
97
|
+
export function familyGroupLoaded(ctx: PluginCardContext): boolean {
|
|
98
|
+
const get = ctx.get
|
|
99
|
+
if (typeof get !== 'function') return false
|
|
100
|
+
try {
|
|
101
|
+
return get.call(ctx, FAMILY_GROUP_SERVICE) !== undefined
|
|
102
|
+
} catch {
|
|
103
|
+
// A context that refuses the lookup: treat the group as absent, which
|
|
104
|
+
// falls back to the official seat the harness declares.
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Report a refused registration instead of leaving the user with no card. */
|
|
110
|
+
function warnRefusedSeat(seat: string, error: unknown): void {
|
|
111
|
+
try {
|
|
112
|
+
console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error)
|
|
113
|
+
} catch {
|
|
114
|
+
// Best-effort console write; a failed warning must not break the plugin.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Contribute one family plugin card to the seat this host renders, following
|
|
120
|
+
* the group if it loads later. The entry is disposed and re-registered on a
|
|
121
|
+
* seat change, never duplicated.
|
|
122
|
+
* @param ctx - client context (its slot registry decides the seat).
|
|
123
|
+
* @param seat - the card contribution.
|
|
124
|
+
*/
|
|
125
|
+
export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat): void {
|
|
126
|
+
const slots = ctx.slots
|
|
127
|
+
const component = seat.component as never
|
|
128
|
+
const inject = seat.inject as never
|
|
129
|
+
|
|
130
|
+
let dispose: (() => void) | undefined
|
|
131
|
+
let current: string | undefined
|
|
132
|
+
/**
|
|
133
|
+
* Re-entrancy latch. The registry emits a change event synchronously from
|
|
134
|
+
* inside both `register` and the previous entry's disposer, so an unguarded
|
|
135
|
+
* reconcile would re-enter itself mid-move and register the card twice into
|
|
136
|
+
* the seat it is leaving ("already has an entry for key ...").
|
|
137
|
+
*/
|
|
138
|
+
let reconciling = false
|
|
139
|
+
|
|
140
|
+
/** Reconcile the contribution with the currently live seat (no-op when unchanged). */
|
|
141
|
+
const reconcile = (): void => {
|
|
142
|
+
if (reconciling) return
|
|
143
|
+
const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT
|
|
144
|
+
if (current === target) return
|
|
145
|
+
reconciling = true
|
|
146
|
+
const previous = dispose
|
|
147
|
+
dispose = undefined
|
|
148
|
+
current = undefined
|
|
149
|
+
previous?.()
|
|
150
|
+
try {
|
|
151
|
+
dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
|
|
152
|
+
? {
|
|
153
|
+
name: FAMILY_PLUGIN_CARD_SEAT,
|
|
154
|
+
id: seat.id,
|
|
155
|
+
...(seat.order === undefined ? {} : { order: seat.order }),
|
|
156
|
+
...(seat.label === undefined ? {} : { label: seat.label }),
|
|
157
|
+
locale: seat.locale,
|
|
158
|
+
...(seat.inject === undefined ? {} : { inject }),
|
|
159
|
+
}
|
|
160
|
+
: {
|
|
161
|
+
name: OFFICIAL_PLUGIN_CARD_SEAT,
|
|
162
|
+
key: seat.namespace,
|
|
163
|
+
locale: seat.locale,
|
|
164
|
+
...(seat.inject === undefined ? {} : { inject }),
|
|
165
|
+
}) as never, component) as () => void
|
|
166
|
+
current = target
|
|
167
|
+
} catch (error) {
|
|
168
|
+
warnRefusedSeat(target, error)
|
|
169
|
+
} finally {
|
|
170
|
+
reconciling = false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (typeof ctx.on === 'function') {
|
|
175
|
+
try {
|
|
176
|
+
ctx.on('slots/changed', () => { reconcile() })
|
|
177
|
+
} catch {
|
|
178
|
+
// An event seat that refuses the subscription leaves the initial
|
|
179
|
+
// decision in place; the card still renders in the seat chosen below.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// The initial decision: the group, when it is already loaded (the aggregate
|
|
183
|
+
// order), otherwise the official seat the harness declares.
|
|
184
|
+
reconcile()
|
|
185
|
+
}
|
|
@@ -16,6 +16,33 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* One trailing action button seated at the entry row's right edge (e.g. a
|
|
21
|
+
* refresh command or a collapse chevron). Rows with actions switch from the
|
|
22
|
+
* classic single-button structure to a container with a main button plus the
|
|
23
|
+
* action buttons (nested interactive elements are invalid HTML); rows without
|
|
24
|
+
* actions keep the classic structure untouched.
|
|
25
|
+
*/
|
|
26
|
+
export interface SidebarEntryAction {
|
|
27
|
+
/** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
|
|
28
|
+
id: string
|
|
29
|
+
/** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
|
|
30
|
+
icon: string
|
|
31
|
+
/**
|
|
32
|
+
* Optional icon while the row is inactive. Setting it makes the action a
|
|
33
|
+
* state mirror (a collapse chevron): the core swaps the icon and stamps
|
|
34
|
+
* aria-expanded on every active-state change. Requires the active bridge.
|
|
35
|
+
*/
|
|
36
|
+
inactiveIcon?: string
|
|
37
|
+
/** Localized accessible label (aria-label + title); receives the active state. */
|
|
38
|
+
label(active: boolean): string
|
|
39
|
+
/**
|
|
40
|
+
* Click handler; receives the button so the caller can drive transient
|
|
41
|
+
* affordances (a refresh cooldown) without a re-render.
|
|
42
|
+
*/
|
|
43
|
+
onClick(button: HTMLButtonElement): void
|
|
44
|
+
}
|
|
45
|
+
|
|
19
46
|
/** Per-package configuration for one sidebar entry row. */
|
|
20
47
|
export interface SidebarEntryOptions {
|
|
21
48
|
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
@@ -47,6 +74,12 @@ export interface SidebarEntryOptions {
|
|
|
47
74
|
refresh?: { subscribe(listener: () => void): () => void }
|
|
48
75
|
/** Click action (open/toggle the owning panel). */
|
|
49
76
|
onToggle(): void
|
|
77
|
+
/**
|
|
78
|
+
* Optional trailing action buttons at the row's right edge. Requires the
|
|
79
|
+
* package CSS to seat entryMain / entryAction; empty keeps the classic
|
|
80
|
+
* single-button row.
|
|
81
|
+
*/
|
|
82
|
+
actions?: readonly SidebarEntryAction[]
|
|
50
83
|
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
51
84
|
position: 'before' | 'after'
|
|
52
85
|
/**
|
|
@@ -84,10 +117,12 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
|
84
117
|
return undefined
|
|
85
118
|
}
|
|
86
119
|
|
|
87
|
-
/** Build the entry row (
|
|
88
|
-
function createEntry(options: SidebarEntryOptions): { entry:
|
|
89
|
-
const
|
|
90
|
-
|
|
120
|
+
/** Build the entry row (detached; inserted once the shell is up). */
|
|
121
|
+
function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void; setOpen: (open: boolean) => void } {
|
|
122
|
+
const actions = options.actions ?? []
|
|
123
|
+
const composite = actions.length > 0
|
|
124
|
+
const entry = document.createElement(composite ? 'div' : 'button') as HTMLElement
|
|
125
|
+
if (!composite) (entry as HTMLButtonElement).type = 'button'
|
|
91
126
|
entry.setAttribute(options.rowAttribute, '')
|
|
92
127
|
if (options.plugin !== undefined) {
|
|
93
128
|
entry.setAttribute('data-dsh-plugin', options.plugin)
|
|
@@ -99,19 +134,66 @@ function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement;
|
|
|
99
134
|
const iconSpan = document.createElement('span')
|
|
100
135
|
iconSpan.className = options.css['entryIcon'] ?? ''
|
|
101
136
|
iconSpan.innerHTML = options.icon
|
|
102
|
-
|
|
137
|
+
// The main hit area: the classic row IS the button; with trailing actions
|
|
138
|
+
// the row becomes a container and the main area its own button, so the row
|
|
139
|
+
// never nests interactive elements.
|
|
140
|
+
const main = composite ? document.createElement('button') : (entry as HTMLButtonElement)
|
|
141
|
+
if (composite) {
|
|
142
|
+
main.type = 'button'
|
|
143
|
+
main.className = options.css['entryMain'] ?? ''
|
|
144
|
+
main.append(iconSpan, labelSpan)
|
|
145
|
+
entry.append(main)
|
|
146
|
+
} else {
|
|
147
|
+
entry.append(iconSpan, labelSpan)
|
|
148
|
+
}
|
|
149
|
+
const actionButtons: { action: SidebarEntryAction; button: HTMLButtonElement }[] = []
|
|
150
|
+
for (const action of actions) {
|
|
151
|
+
const button = document.createElement('button')
|
|
152
|
+
button.type = 'button'
|
|
153
|
+
button.className = options.css['entryAction'] ?? ''
|
|
154
|
+
button.setAttribute('data-dsh-entry-action', action.id)
|
|
155
|
+
button.innerHTML = action.icon
|
|
156
|
+
button.addEventListener('click', () => { action.onClick(button) })
|
|
157
|
+
entry.append(button)
|
|
158
|
+
actionButtons.push({ action, button })
|
|
159
|
+
}
|
|
160
|
+
let open = false
|
|
161
|
+
const applyActions = (): void => {
|
|
162
|
+
for (const { action, button } of actionButtons) {
|
|
163
|
+
button.innerHTML = open || action.inactiveIcon === undefined ? action.icon : action.inactiveIcon
|
|
164
|
+
const text = action.label(open)
|
|
165
|
+
button.setAttribute('aria-label', text)
|
|
166
|
+
button.setAttribute('title', text)
|
|
167
|
+
if (action.inactiveIcon !== undefined) button.setAttribute('aria-expanded', String(open))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
103
170
|
const applyLabel = (): void => {
|
|
104
|
-
|
|
105
|
-
if (options.tooltip !== undefined)
|
|
171
|
+
main.setAttribute('aria-label', options.label())
|
|
172
|
+
if (options.tooltip !== undefined) main.setAttribute('title', options.tooltip())
|
|
106
173
|
labelSpan.textContent = options.label()
|
|
174
|
+
applyActions()
|
|
107
175
|
}
|
|
108
176
|
applyLabel()
|
|
109
|
-
|
|
110
|
-
|
|
177
|
+
main.addEventListener('click', options.onToggle)
|
|
178
|
+
if (composite) {
|
|
179
|
+
// The old row was clickable edge to edge; keep the padding between the
|
|
180
|
+
// main area and the action buttons a toggle target as well.
|
|
181
|
+
entry.addEventListener('click', (event) => {
|
|
182
|
+
if (event.target === entry) options.onToggle()
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
entry,
|
|
187
|
+
applyLabel,
|
|
188
|
+
setOpen: (next: boolean) => {
|
|
189
|
+
open = next
|
|
190
|
+
applyActions()
|
|
191
|
+
},
|
|
192
|
+
}
|
|
111
193
|
}
|
|
112
194
|
|
|
113
195
|
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
114
|
-
function placeEntry(root: HTMLElement, entry:
|
|
196
|
+
function placeEntry(root: HTMLElement, entry: HTMLElement, options: SidebarEntryOptions): boolean {
|
|
115
197
|
const button = newSessionButton(root)
|
|
116
198
|
if (button === undefined) return false
|
|
117
199
|
if (entry.parentElement !== root) {
|
|
@@ -149,7 +231,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
|
149
231
|
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
|
150
232
|
return () => {}
|
|
151
233
|
}
|
|
152
|
-
const { entry, applyLabel } = createEntry(options)
|
|
234
|
+
const { entry, applyLabel, setOpen } = createEntry(options)
|
|
153
235
|
let root: HTMLElement | undefined
|
|
154
236
|
let placed = false
|
|
155
237
|
let unsubscribeRefresh: (() => void) | undefined
|
|
@@ -217,8 +299,10 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
|
217
299
|
// row permanently highlighted — delete the attribute instead.
|
|
218
300
|
const unsubscribeActive = options.active === undefined ? undefined : (() => {
|
|
219
301
|
const syncActive = (): void => {
|
|
220
|
-
|
|
302
|
+
const open = options.active!.isOpen()
|
|
303
|
+
if (open) entry.dataset.active = 'true'
|
|
221
304
|
else delete entry.dataset.active
|
|
305
|
+
setOpen(open)
|
|
222
306
|
}
|
|
223
307
|
const unsubscribe = options.active.subscribe(syncActive)
|
|
224
308
|
syncActive()
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
* previous execution's conversation. Pure, so the fail-closed conditions stay
|
|
4
4
|
* unit-testable without a gateway.
|
|
5
5
|
*/
|
|
6
|
-
import type { TaskRecord } from './tasks.ts'
|
|
6
|
+
import type { ExecutionRecord, TaskRecord } from './tasks.ts'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Pick the session a new execution of this task may continue in, or undefined
|
|
10
10
|
* to mint a fresh conversation. Reuse requires ALL of:
|
|
11
11
|
* - the task opted in (`reuseSession === true`);
|
|
12
|
-
* - the task's newest execution carries a session id
|
|
13
|
-
*
|
|
12
|
+
* - the task's newest SETTLED execution carries a session id (issue #1587: the
|
|
13
|
+
* launch path calls this after `startExecution` appended this run's own open
|
|
14
|
+
* record, so reading the array tail always found an unsettled row and reuse
|
|
15
|
+
* never happened);
|
|
14
16
|
* - the roster is known and that session is present and idle.
|
|
15
17
|
*
|
|
16
18
|
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
@@ -25,7 +27,14 @@ export function reusableSessionId(
|
|
|
25
27
|
idleSessionIds: ReadonlySet<string> | undefined,
|
|
26
28
|
): string | undefined {
|
|
27
29
|
if (task.reuseSession !== true || idleSessionIds === undefined) return undefined
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
let last: ExecutionRecord | undefined
|
|
31
|
+
for (let index = task.executions.length - 1; index >= 0; index -= 1) {
|
|
32
|
+
const candidate = task.executions[index]
|
|
33
|
+
if (candidate.sessionId !== undefined && candidate.endedAt !== undefined) {
|
|
34
|
+
last = candidate
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (last === undefined || last.sessionId === undefined) return undefined
|
|
30
39
|
return idleSessionIds.has(last.sessionId) ? last.sessionId : undefined
|
|
31
40
|
}
|
package/src/host-ledger.ts
CHANGED
|
@@ -134,6 +134,11 @@ export function processIsAlive(pid: number): boolean {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
const PROCESS_PROBE_TIMEOUT_MS = 3000
|
|
137
|
+
/**
|
|
138
|
+
* The CIM fallback pays a WMI cold start, so it gets a wider budget than the
|
|
139
|
+
* direct `Get-Process` read it backs up.
|
|
140
|
+
*/
|
|
141
|
+
const CIM_PROBE_TIMEOUT_MS = 8000
|
|
137
142
|
|
|
138
143
|
let ownStartTime: number | undefined
|
|
139
144
|
let ownStartTimeResolved = false
|
|
@@ -161,6 +166,59 @@ function linuxStartTimeMs(pid: number): number | undefined {
|
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
/** Runs one PowerShell script and returns its trimmed stdout. */
|
|
170
|
+
export type PowerShellProbe = (script: string, timeoutMs: number) => string | undefined
|
|
171
|
+
|
|
172
|
+
/** Default probe: one hidden, profile-free PowerShell process per script. */
|
|
173
|
+
const runPowerShellProbe: PowerShellProbe = (script, timeoutMs) => {
|
|
174
|
+
const probe = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
175
|
+
timeout: timeoutMs,
|
|
176
|
+
windowsHide: true,
|
|
177
|
+
})
|
|
178
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
179
|
+
return probe.stdout.toString('utf8').trim()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The epoch-millisecond reading a probe printed, or undefined when unusable. */
|
|
183
|
+
function parseProbeEpochMs(raw: string | undefined): number | undefined {
|
|
184
|
+
if (raw === undefined || raw.trim() === '') return undefined
|
|
185
|
+
const started = Number(raw.trim())
|
|
186
|
+
return Number.isFinite(started) ? started : undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Start time through Get-Process: precise, but empty for protected processes. */
|
|
190
|
+
function getProcessStartScript(pid: number): string {
|
|
191
|
+
return '[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid)
|
|
192
|
+
+ ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Start time through Win32_Process: readable for System/svchost too. */
|
|
196
|
+
function cimStartScript(pid: number): string {
|
|
197
|
+
return '$p=Get-CimInstance Win32_Process -Filter "ProcessId=' + String(pid)
|
|
198
|
+
+ '" -ErrorAction SilentlyContinue;if($p -ne $null){[DateTimeOffset]::FromFileTime($p.CreationDate.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()}'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Windows start time (Unix epoch ms) of a live process.
|
|
203
|
+
*
|
|
204
|
+
* `Get-Process` is the precise first choice, but an unprivileged caller cannot
|
|
205
|
+
* read `.StartTime` for a protected process (System, svchost): the property is
|
|
206
|
+
* empty, so the probe returns nothing. Without a fallback, a crash leftover
|
|
207
|
+
* lock whose PID was reused by such a process could never be proven stale and
|
|
208
|
+
* blocked every startup until the lock was deleted by hand (issue #1629).
|
|
209
|
+
* Win32_Process through CIM reports the same CreationDate for those processes
|
|
210
|
+
* at the same millisecond precision, so it is the second identity source. The
|
|
211
|
+
* probe is injectable so the fallback chain is testable off Windows.
|
|
212
|
+
*/
|
|
213
|
+
export function win32StartTimeMs(pid: number, probe: PowerShellProbe = runPowerShellProbe): number | undefined {
|
|
214
|
+
// The pid is interpolated into a PowerShell script, so it must be a plain
|
|
215
|
+
// positive integer before any probe runs.
|
|
216
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined
|
|
217
|
+
const direct = parseProbeEpochMs(probe(getProcessStartScript(pid), PROCESS_PROBE_TIMEOUT_MS))
|
|
218
|
+
if (direct !== undefined) return direct
|
|
219
|
+
return parseProbeEpochMs(probe(cimStartScript(pid), CIM_PROBE_TIMEOUT_MS))
|
|
220
|
+
}
|
|
221
|
+
|
|
164
222
|
/**
|
|
165
223
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
166
224
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
@@ -170,17 +228,7 @@ function linuxStartTimeMs(pid: number): number | undefined {
|
|
|
170
228
|
*/
|
|
171
229
|
function processStartTimeMs(pid: number): number | undefined {
|
|
172
230
|
if (process.platform === 'linux') return linuxStartTimeMs(pid)
|
|
173
|
-
if (process.platform === 'win32')
|
|
174
|
-
const probe = spawnSync(
|
|
175
|
-
'powershell',
|
|
176
|
-
['-NoProfile', '-NonInteractive', '-Command',
|
|
177
|
-
'[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid) + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()'],
|
|
178
|
-
{ timeout: PROCESS_PROBE_TIMEOUT_MS, windowsHide: true },
|
|
179
|
-
)
|
|
180
|
-
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
181
|
-
const started = Number(probe.stdout.toString('utf8').trim())
|
|
182
|
-
return Number.isFinite(started) ? started : undefined
|
|
183
|
-
}
|
|
231
|
+
if (process.platform === 'win32') return win32StartTimeMs(pid)
|
|
184
232
|
// Other POSIX (macOS...): ps lstart with a forced English locale, falling
|
|
185
233
|
// back to the elapsed-seconds column when lstart cannot be parsed.
|
|
186
234
|
const env = { ...process.env, LC_ALL: 'C' }
|