webhelm 1.0.20 → 1.0.22
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/package.json +1 -1
- package/runtime/chatgpt/chatgpt-queue.mjs +9 -2
- package/runtime/durable-queue-core.mjs +16 -1
- package/runtime/extension/control-channel.mjs +1 -1
- package/runtime/extension/control-rpc.mjs +46 -2
- package/runtime/extension/popup.html +14 -5
- package/runtime/extension/popup.mjs +78 -20
- package/runtime/extension/service-worker.mjs +11 -1
- package/runtime/extension/workspace-manager.mjs +125 -2
- package/runtime/node-daemon.mjs +2 -0
package/package.json
CHANGED
|
@@ -60,11 +60,18 @@ const chatgptQueuePolicy = {
|
|
|
60
60
|
db.prepare("UPDATE turns SET state='CHECKING',lease_owner=?,lease_until=?,attempts=attempts+1 WHERE id=?").run(owner,now+leaseSeconds,row.id);
|
|
61
61
|
return this.one(db, "SELECT * FROM turns WHERE id=?", row.id);
|
|
62
62
|
},
|
|
63
|
+
// A queue adapter registers a display description; Actions stay unaware of queues.
|
|
64
|
+
describe(row) {
|
|
65
|
+
const firstLine = row.message.split(/\r?\n/, 1)[0].replace(/\s+/g, ' ').trim();
|
|
66
|
+
const title = firstLine ? (firstLine.length > 52 ? `${firstLine.slice(0, 51)}…` : firstLine) : 'ChatGPT message';
|
|
67
|
+
const prefix = row.project_id ? `/g/${encodeURIComponent(row.project_id)}` : '';
|
|
68
|
+
return {platform:'ChatGPT', title, url:`https://chatgpt.com${prefix}/c/${encodeURIComponent(row.thread_id)}`};
|
|
69
|
+
},
|
|
63
70
|
cancel(db, id) {
|
|
64
71
|
const row=this.one(db, "SELECT * FROM turns WHERE id=?", id);
|
|
65
72
|
if (!row) return {ok:false,queue_id:id,error:"queue item not found"};
|
|
66
|
-
if (row.state
|
|
67
|
-
db.prepare("UPDATE turns SET state='CANCELLED',lease_owner=NULL,lease_until=NULL WHERE id=? AND state
|
|
73
|
+
if (!["QUEUED","WAITING","BLOCKED","UNCERTAIN"].includes(row.state)) return {ok:false,queue_id:id,state:row.state,error:"cannot remove a running or completed item"};
|
|
74
|
+
db.prepare("UPDATE turns SET state='CANCELLED',lease_owner=NULL,lease_until=NULL WHERE id=? AND state IN ('QUEUED','WAITING','BLOCKED','UNCERTAIN')").run(id);
|
|
68
75
|
return {ok:true,queue_id:id,state:"CANCELLED"};
|
|
69
76
|
},
|
|
70
77
|
list(db) {
|
|
@@ -42,7 +42,22 @@ export class DurableQueueCore {
|
|
|
42
42
|
cancel(id) {
|
|
43
43
|
return this._transaction(() => this.policy.cancel(this.db, id));
|
|
44
44
|
}
|
|
45
|
-
list() {
|
|
45
|
+
list() {
|
|
46
|
+
const result = this.policy.list(this.db);
|
|
47
|
+
if (typeof this.policy.describe !== 'function') return result;
|
|
48
|
+
const rows = new Map(this.rows().map(row => [row.id, row]));
|
|
49
|
+
return {...result, items: result.items.map(item => {
|
|
50
|
+
const row = rows.get(item.id);
|
|
51
|
+
const description = row && this.policy.describe(row);
|
|
52
|
+
if (!description) return item;
|
|
53
|
+
return {
|
|
54
|
+
...item,
|
|
55
|
+
...(description.title !== undefined ? {title:description.title} : {}),
|
|
56
|
+
...(description.url !== undefined ? {url:description.url} : {}),
|
|
57
|
+
...(description.platform !== undefined ? {platform:description.platform} : {}),
|
|
58
|
+
};
|
|
59
|
+
})};
|
|
60
|
+
}
|
|
46
61
|
start() {
|
|
47
62
|
if (this.started) return this;
|
|
48
63
|
this.started = true;
|
|
@@ -85,7 +85,7 @@ export class ControlChannel {
|
|
|
85
85
|
if (state.request) throw new Error('Another WebHelm operation is already running');
|
|
86
86
|
if (!Number.isFinite(state.daemon?.seenAt) || this.now() - state.daemon.seenAt >= FRESH_MS)
|
|
87
87
|
throw new Error('WebHelm daemon is not responding');
|
|
88
|
-
if (!(state.queue || []).some(item => item.id === queueId && item.state
|
|
88
|
+
if (!(state.queue || []).some(item => item.id === queueId && ['QUEUED','WAITING','BLOCKED','UNCERTAIN'].includes(item.state)))
|
|
89
89
|
throw new Error('Queue item is no longer cancellable');
|
|
90
90
|
const request = {id:this.makeId(), action:'queue-cancel', queueId, state:'pending', createdAt:this.now()};
|
|
91
91
|
await this.save({...state, request, lastResult:null});
|
|
@@ -1,10 +1,54 @@
|
|
|
1
|
-
|
|
1
|
+
function validTabId(tabId) { return Number.isSafeInteger(tabId) && tabId > 0; }
|
|
2
|
+
function validateQueueUrl(url) {
|
|
3
|
+
const parsed = new URL(url);
|
|
4
|
+
if (parsed.protocol !== 'https:' || !parsed.hostname) throw Error('Queue target must be an HTTPS URL');
|
|
5
|
+
return parsed.href;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createControlRpc({control, manager, summarizeWorkspaces, scheduleBadge, chromeApi=globalThis.chrome ?? control.chrome}) {
|
|
2
9
|
return async function controlRpc(method, args = {}) {
|
|
3
10
|
switch (method) {
|
|
4
11
|
case "workspace.control.status": {
|
|
5
12
|
const state = await control.status();
|
|
6
13
|
const summary = summarizeWorkspaces((await manager.list()).workspaces, state.sessions);
|
|
7
|
-
|
|
14
|
+
const workspaces = await Promise.all(summary.workspaces.map(async workspace => ({
|
|
15
|
+
...workspace,
|
|
16
|
+
sessions: await Promise.all(workspace.sessions.map(async session => {
|
|
17
|
+
if (!validTabId(session.tabId)) return session;
|
|
18
|
+
try {
|
|
19
|
+
const tab = await chromeApi.tabs.get(session.tabId);
|
|
20
|
+
return {...session, tab_title: tab.title || 'Untitled tab'};
|
|
21
|
+
} catch { return {...session, tabId:null}; }
|
|
22
|
+
})),
|
|
23
|
+
})));
|
|
24
|
+
return {...state,...summary,workspaces};
|
|
25
|
+
}
|
|
26
|
+
case "workspace.control.activateTab": {
|
|
27
|
+
const state = await control.status();
|
|
28
|
+
if (!state.connected) throw Error('WebHelm daemon is not responding');
|
|
29
|
+
const session = state.sessions.find(s => s.session_id === args.sessionId);
|
|
30
|
+
if (!session || !validTabId(session.tabId)) throw Error('Session tab is no longer active');
|
|
31
|
+
const tab = await chromeApi.tabs.get(session.tabId);
|
|
32
|
+
await chromeApi.tabs.update(tab.id,{active:true});
|
|
33
|
+
await chromeApi.windows.update(tab.windowId,{focused:true});
|
|
34
|
+
return {ok:true};
|
|
35
|
+
}
|
|
36
|
+
case "workspace.control.openQueue": {
|
|
37
|
+
const state = await control.status();
|
|
38
|
+
if (!state.connected) throw Error('WebHelm daemon is not responding');
|
|
39
|
+
const item = state.queue.find(item => item.id === args.queueId);
|
|
40
|
+
if (!item || typeof item.url !== 'string') throw Error('Queue entry no longer available');
|
|
41
|
+
const url = validateQueueUrl(item.url);
|
|
42
|
+
const existing = (await chromeApi.tabs.query({})).find(tab => {
|
|
43
|
+
try { return new URL(tab.url).href === url; } catch { return false; }
|
|
44
|
+
});
|
|
45
|
+
if (existing) {
|
|
46
|
+
await chromeApi.tabs.update(existing.id,{active:true});
|
|
47
|
+
await chromeApi.windows.update(existing.windowId,{focused:true});
|
|
48
|
+
return {ok:true,tabId:existing.id,focused:true};
|
|
49
|
+
}
|
|
50
|
+
const opened = await chromeApi.tabs.create({url, active:true});
|
|
51
|
+
return {ok:true,tabId:opened.id,opened:true};
|
|
8
52
|
}
|
|
9
53
|
case "workspace.control.requestStop":
|
|
10
54
|
return control.requestStop(args.action, args.sessionId, args.workspace);
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
#confirmation p { color:inherit; }
|
|
23
23
|
.buttons { display:flex; gap:8px; }
|
|
24
24
|
.session-list { max-height:360px; overflow-y:auto; }
|
|
25
|
-
.workspace-row { padding:
|
|
26
|
-
.workspace-row
|
|
25
|
+
.workspace-row { padding:7px 0; }
|
|
26
|
+
.workspace-row + .workspace-row { border-top:1px solid light-dark(var(--border),#334155); }
|
|
27
27
|
.workspace-title { display:flex; align-items:center; gap:8px; justify-content:space-between; }
|
|
28
28
|
.workspace-title strong { font-size:13px; overflow-wrap:anywhere; }
|
|
29
29
|
.workspace-title button { width:auto; flex-shrink:0; padding:4px 8px; font-size:11px; }
|
|
@@ -33,10 +33,20 @@
|
|
|
33
33
|
.session-info strong { display:block; font-size:12px; }
|
|
34
34
|
.session-info span { display:block; color:light-dark(var(--muted),#acb8c9); font-size:11px; }
|
|
35
35
|
.session-row button { width:auto; padding:5px 9px; font-size:11px; }
|
|
36
|
-
|
|
36
|
+
#queue-list { border-top:1px solid light-dark(var(--border),#334155); padding-top:6px; margin-top:2px; }
|
|
37
|
+
.queue-platform { padding:5px 0; }
|
|
38
|
+
.queue-platform + .queue-platform { border-top:1px solid light-dark(var(--border),#334155); }
|
|
39
|
+
.queue-platform-title { font-size:12px; }
|
|
40
|
+
.queue-row { display:flex; align-items:center; gap:8px; padding:5px 0; }
|
|
37
41
|
.queue-info { min-width:0; flex:1; overflow-wrap:anywhere; }
|
|
38
42
|
.queue-info span { display:block; color:light-dark(var(--muted),#acb8c9); font-size:11px; }
|
|
39
43
|
.queue-row button { width:auto; padding:5px 9px; font-size:11px; }
|
|
44
|
+
button.tab-title, button.queue-title { flex:1; min-width:0; width:100%; padding:0; border:0; border-radius:2px; background:transparent; color:inherit; text-align:left; font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
|
|
45
|
+
button.tab-title:hover, button.queue-title:hover { text-decoration:underline; }
|
|
46
|
+
button.tab-title:disabled, button.queue-title:disabled { cursor:default; text-decoration:none; }
|
|
47
|
+
.session-info, .queue-info { overflow:hidden; }
|
|
48
|
+
.queue-info .queue-title { margin:0; }
|
|
49
|
+
|
|
40
50
|
#message { margin-top:8px; font-size:11px; overflow-wrap:anywhere; }
|
|
41
51
|
#message:empty { display:none; }
|
|
42
52
|
[hidden] { display:none !important; }
|
|
@@ -48,8 +58,7 @@
|
|
|
48
58
|
<span id="daemon-status" class="daemon-state" role="status">Connecting…</span>
|
|
49
59
|
</header>
|
|
50
60
|
<div id="workspace-list" class="session-list" aria-label="Workspaces" aria-live="polite">Connecting…</div>
|
|
51
|
-
<
|
|
52
|
-
<div id="queue-list" class="session-list" aria-label="ChatGPT queue" aria-live="polite">No queued turns</div>
|
|
61
|
+
<div id="queue-list" class="session-list" aria-label="Queue" aria-live="polite">No queue entries</div>
|
|
53
62
|
<section id="confirmation" hidden aria-label="Confirm clear sessions">
|
|
54
63
|
<p id="confirm-text">Stop the selected WebHelm session(s)? Tasks in progress may be interrupted. Queued jobs remain and may create new sessions.</p>
|
|
55
64
|
<div class="buttons">
|
|
@@ -2,7 +2,10 @@ const el = id => document.getElementById(id);
|
|
|
2
2
|
let confirming = false;
|
|
3
3
|
let chosen = null;
|
|
4
4
|
let lastSignature = '';
|
|
5
|
+
let lastQueueSignature = '';
|
|
5
6
|
let refreshRunning = false;
|
|
7
|
+
const queueRows = new Map();
|
|
8
|
+
const queueGroups = new Map();
|
|
6
9
|
|
|
7
10
|
async function rpc(method, args = {}) {
|
|
8
11
|
const response = await chrome.runtime.sendMessage({method, args});
|
|
@@ -23,9 +26,10 @@ function showConfirmation(action, params = {}) {
|
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
function actionButton(label, disabled, onClick) {
|
|
29
|
+
function actionButton(label, disabled, onClick, className = '') {
|
|
27
30
|
const button = document.createElement('button');
|
|
28
31
|
button.textContent = label;
|
|
32
|
+
if (className) button.className = className;
|
|
29
33
|
button.disabled = disabled;
|
|
30
34
|
button.addEventListener('click', onClick);
|
|
31
35
|
return button;
|
|
@@ -33,7 +37,7 @@ function actionButton(label, disabled, onClick) {
|
|
|
33
37
|
|
|
34
38
|
function renderWorkspaces(workspaces, busy, connected) {
|
|
35
39
|
const signature = JSON.stringify(workspaces.map(w => [
|
|
36
|
-
w.name,w.initialized,w.available,w.poolSize,w.idleTabs,w.leasedTabs,w.sessions.map(s => s.session_id),
|
|
40
|
+
w.name,w.initialized,w.available,w.poolSize,w.idleTabs,w.leasedTabs,w.sessions.map(s => [s.session_id,s.tab_title,s.tabId]),
|
|
37
41
|
])) + ':' + busy + ':' + connected;
|
|
38
42
|
if (signature === lastSignature) return;
|
|
39
43
|
lastSignature = signature;
|
|
@@ -63,9 +67,12 @@ function renderWorkspaces(workspaces, busy, connected) {
|
|
|
63
67
|
sessionRow.className = 'session-row';
|
|
64
68
|
const details = document.createElement('div');
|
|
65
69
|
details.className = 'session-info';
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
const title = actionButton(session.tab_title || 'Untitled tab', busy || !connected || !session.tabId, async () => {
|
|
71
|
+
try { await rpc('workspace.control.activateTab', {sessionId:session.session_id}); }
|
|
72
|
+
catch(error) { el('message').textContent=error.message; }
|
|
73
|
+
}, 'tab-title');
|
|
74
|
+
title.title = session.tab_title || 'Untitled tab';
|
|
75
|
+
details.append(title);
|
|
69
76
|
sessionRow.append(details, actionButton('Stop', busy || !connected,
|
|
70
77
|
() => showConfirmation('stop', {sessionId:session.session_id})));
|
|
71
78
|
row.append(sessionRow);
|
|
@@ -75,21 +82,72 @@ function renderWorkspaces(workspaces, busy, connected) {
|
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
function renderQueue(items, busy, connected) {
|
|
85
|
+
const signature=JSON.stringify(items.map(item=>[item.id,item.platform,item.title,item.url,item.state,item.attempts]))+`:${busy}:${connected}`;
|
|
86
|
+
if (signature===lastQueueSignature) return;
|
|
87
|
+
lastQueueSignature=signature;
|
|
78
88
|
const target=el('queue-list');
|
|
79
|
-
|
|
80
|
-
if (!items.length) { target.textContent='No active turns'; return; }
|
|
89
|
+
const grouped = new Map();
|
|
81
90
|
for (const item of items) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
const platform = String(item.platform || 'Queue');
|
|
92
|
+
if (!grouped.has(platform)) grouped.set(platform, []);
|
|
93
|
+
grouped.get(platform).push(item);
|
|
94
|
+
}
|
|
95
|
+
const activeGroupKeys = new Set(grouped.keys());
|
|
96
|
+
const activeRowKeys = new Set(items.map(item => `${item.platform || 'Queue'}:${item.id}`));
|
|
97
|
+
for (const [key, group] of queueGroups) if (!activeGroupKeys.has(key)) {
|
|
98
|
+
group.remove(); queueGroups.delete(key);
|
|
99
|
+
}
|
|
100
|
+
for (const [key, row] of queueRows) if (!activeRowKeys.has(key)) {
|
|
101
|
+
row.remove(); queueRows.delete(key);
|
|
102
|
+
}
|
|
103
|
+
if (!items.length) {
|
|
104
|
+
target.replaceChildren();
|
|
105
|
+
const empty=document.createElement('div'); empty.className='empty-queue'; empty.textContent='No queue entries';
|
|
106
|
+
target.append(empty);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
target.querySelector('.empty-queue')?.remove();
|
|
110
|
+
for (const [platform, entries] of grouped) {
|
|
111
|
+
let group = queueGroups.get(platform);
|
|
112
|
+
if (!group) {
|
|
113
|
+
group = document.createElement('section'); group.className='queue-platform';
|
|
114
|
+
const heading = document.createElement('strong'); heading.className='queue-platform-title';
|
|
115
|
+
heading.textContent=platform==='Queue'?'Queue':`${platform} Queue`;
|
|
116
|
+
const rows = document.createElement('div'); rows.className='queue-platform-rows';
|
|
117
|
+
group.append(heading, rows); queueGroups.set(platform, group); target.append(group);
|
|
118
|
+
}
|
|
119
|
+
for (const item of entries) {
|
|
120
|
+
const key = `${platform}:${item.id}`;
|
|
121
|
+
let row = queueRows.get(key);
|
|
122
|
+
if (!row) {
|
|
123
|
+
row=document.createElement('div'); row.className='queue-row';
|
|
124
|
+
const info=document.createElement('div'); info.className='queue-info';
|
|
125
|
+
const title=actionButton('', false, async()=>{
|
|
126
|
+
try { await rpc('workspace.control.openQueue',{queueId:item.id}); el('message').textContent='Queue destination opened.'; }
|
|
127
|
+
catch(error) { el('message').textContent=error.message; }
|
|
128
|
+
}, 'queue-title');
|
|
129
|
+
const detail=document.createElement('span'); detail.className='queue-detail';
|
|
130
|
+
const remove=actionButton('Remove', false, async()=>{
|
|
131
|
+
try { const result=await rpc('workspace.control.queueCancel',{queueId:item.id}); if(!result.id) throw new Error('Missing queue request ID'); el('message').textContent='Waiting for WebHelm daemon…'; }
|
|
132
|
+
catch(error) { el('message').textContent=error.message; }
|
|
133
|
+
refresh();
|
|
134
|
+
});
|
|
135
|
+
row.append(info, remove); info.append(title, detail);
|
|
136
|
+
queueRows.set(key,row);
|
|
137
|
+
}
|
|
138
|
+
const title=row.querySelector('.queue-title');
|
|
139
|
+
title.textContent=item.title || `${platform} message`;
|
|
140
|
+
title.title=item.title || `${platform} message`;
|
|
141
|
+
title.disabled=!connected || !item.url;
|
|
142
|
+
row.querySelector('.queue-detail').textContent=`${item.state.toLowerCase()} · ${item.attempts} attempt${item.attempts===1?'':'s'}`;
|
|
143
|
+
const remove=row.querySelector('button:last-child');
|
|
144
|
+
const safe=['QUEUED','WAITING','BLOCKED','UNCERTAIN'].includes(item.state);
|
|
145
|
+
remove.disabled=!safe || busy || !connected;
|
|
146
|
+
remove.title=safe?'':'Cannot remove a running or completed submission';
|
|
147
|
+
const rows=group.querySelector('.queue-platform-rows');
|
|
148
|
+
if (row.parentElement!==rows) rows.append(row);
|
|
149
|
+
}
|
|
150
|
+
if (group.parentElement!==target) target.append(group);
|
|
93
151
|
}
|
|
94
152
|
}
|
|
95
153
|
|
|
@@ -105,10 +163,10 @@ async function refresh() {
|
|
|
105
163
|
renderQueue(state.queue || [], busy, state.connected);
|
|
106
164
|
el('confirm').disabled = !state.connected || busy;
|
|
107
165
|
if (busy) {
|
|
108
|
-
el('message').textContent = '
|
|
166
|
+
el('message').textContent = 'Applying WebHelm action…';
|
|
109
167
|
} else if (state.lastResult) {
|
|
110
168
|
el('message').textContent = state.lastResult.ok
|
|
111
|
-
? `Last action: stopped ${state.lastResult.stoppedCount} session(s).`
|
|
169
|
+
? (state.lastResult.queue_id ? 'Queue entry removed.' : `Last action: stopped ${state.lastResult.stoppedCount} session(s).`)
|
|
112
170
|
: `Last action failed: ${state.lastResult.error}`;
|
|
113
171
|
}
|
|
114
172
|
} catch (error) {
|
|
@@ -6,6 +6,10 @@ import { createControlRpc } from "./control-rpc.mjs";
|
|
|
6
6
|
|
|
7
7
|
const manager = new WorkspaceManager(chrome);
|
|
8
8
|
const control = new ControlChannel(chrome);
|
|
9
|
+
manager.setOwnershipProvider(async () => {
|
|
10
|
+
const state = await control.status();
|
|
11
|
+
return state.connected ? state.sessions : null;
|
|
12
|
+
});
|
|
9
13
|
const closingWindowIds = new Set();
|
|
10
14
|
let badgeTimer = null;
|
|
11
15
|
|
|
@@ -26,11 +30,16 @@ function scheduleBadge() {
|
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
const BADGE_ALARM = "webhelm.badge-refresh";
|
|
33
|
+
const ORPHAN_ALARM = "webhelm.orphan-lease-recovery";
|
|
29
34
|
async function scheduleBadgeAlarm() {
|
|
30
35
|
await chrome.alarms.create(BADGE_ALARM, {periodInMinutes: 0.5});
|
|
36
|
+
await chrome.alarms.create(ORPHAN_ALARM, {periodInMinutes: 1});
|
|
31
37
|
}
|
|
32
38
|
chrome.alarms.onAlarm.addListener(alarm => {
|
|
33
39
|
if (alarm.name === BADGE_ALARM) scheduleBadge();
|
|
40
|
+
if (alarm.name === ORPHAN_ALARM) manager.recoverConfiguredOrphans().catch(error => {
|
|
41
|
+
console.warn("WebHelm orphan lease recovery failed", error);
|
|
42
|
+
});
|
|
34
43
|
});
|
|
35
44
|
scheduleBadgeAlarm().catch(error => console.warn("WebHelm badge alarm failed", error));
|
|
36
45
|
|
|
@@ -92,7 +101,8 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|
|
92
101
|
}
|
|
93
102
|
if (request?.method?.startsWith("workspace.control.") &&
|
|
94
103
|
(sender?.url !== chrome.runtime.getURL("popup.html") ||
|
|
95
|
-
!["workspace.control.requestStop", "workspace.control.queueCancel", "workspace.control.status"
|
|
104
|
+
!["workspace.control.requestStop", "workspace.control.queueCancel", "workspace.control.status",
|
|
105
|
+
"workspace.control.activateTab", "workspace.control.openQueue"].includes(request.method))) {
|
|
96
106
|
sendResponse({ok:false,error:{message:"WebHelm control actions require the toolbar popup"}});
|
|
97
107
|
return false;
|
|
98
108
|
}
|
|
@@ -2,6 +2,20 @@ const STORAGE_KEY = "browserWorkspaceManager.v1";
|
|
|
2
2
|
const MIN_POOL_SIZE = 1;
|
|
3
3
|
const MAX_POOL_SIZE = 64;
|
|
4
4
|
const EXTENSION_MANAGEMENT_RECENCY_MS = 15_000;
|
|
5
|
+
const ORPHAN_LEASE_GRACE_MS = 60_000;
|
|
6
|
+
const ORPHAN_OBSERVED_KEY = "webhelm.orphan-observed.v1";
|
|
7
|
+
|
|
8
|
+
class Mutex {
|
|
9
|
+
constructor() { this.tail = Promise.resolve(); }
|
|
10
|
+
async run(fn) {
|
|
11
|
+
let release;
|
|
12
|
+
const gate = new Promise(resolve => { release = resolve; });
|
|
13
|
+
const previous = this.tail;
|
|
14
|
+
this.tail = previous.then(() => gate);
|
|
15
|
+
await previous;
|
|
16
|
+
try { return await fn(); } finally { release(); }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
5
19
|
|
|
6
20
|
export function normalizeWorkspaceName(value) {
|
|
7
21
|
const name = String(value || "").trim();
|
|
@@ -23,12 +37,112 @@ function queryString(workspace) {
|
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
export class WorkspaceManager {
|
|
26
|
-
constructor(chromeApi) {
|
|
40
|
+
constructor(chromeApi, { now = () => Date.now(), orphanGraceMs = ORPHAN_LEASE_GRACE_MS } = {}) {
|
|
27
41
|
this.chrome = chromeApi;
|
|
42
|
+
this.now = now;
|
|
43
|
+
this.orphanGraceMs = orphanGraceMs;
|
|
44
|
+
this.getDaemonSessions = async () => null;
|
|
45
|
+
this.workspaceLocks = new Map();
|
|
46
|
+
this.orphanObserved = new Map();
|
|
47
|
+
this.orphanObservedLoaded = false;
|
|
48
|
+
this.inFlightTabIds = new Set();
|
|
28
49
|
this.recentExtensionManagementByWindow = new Map();
|
|
29
50
|
this.ownedTabIds = new Set();
|
|
30
51
|
}
|
|
31
52
|
|
|
53
|
+
setOwnershipProvider(provider) {
|
|
54
|
+
this.getDaemonSessions = provider;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
workspaceLock(name) {
|
|
58
|
+
if (!this.workspaceLocks.has(name)) this.workspaceLocks.set(name, new Mutex());
|
|
59
|
+
return this.workspaceLocks.get(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async loadOrphanObservations() {
|
|
63
|
+
if (this.orphanObservedLoaded) return;
|
|
64
|
+
this.orphanObservedLoaded = true;
|
|
65
|
+
if (!this.chrome.storage.session) return;
|
|
66
|
+
const saved = (await this.chrome.storage.session.get(ORPHAN_OBSERVED_KEY))[ORPHAN_OBSERVED_KEY] || {};
|
|
67
|
+
this.orphanObserved = new Map(Object.entries(saved).map(([key, value]) => [key, Number(value)]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async saveOrphanObservations() {
|
|
71
|
+
if (!this.chrome.storage.session) return;
|
|
72
|
+
await this.chrome.storage.session.set({[ORPHAN_OBSERVED_KEY]:Object.fromEntries(this.orphanObserved)});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async recoverOrphanedLeases(name, { sessions = null, graceMs = this.orphanGraceMs } = {}) {
|
|
76
|
+
await this.loadOrphanObservations();
|
|
77
|
+
const normalizedName = normalizeWorkspaceName(name);
|
|
78
|
+
const { entry } = await this.configuredWorkspace(normalizedName);
|
|
79
|
+
if (!entry) return [];
|
|
80
|
+
const group = await this.workspaceGroup(normalizedName);
|
|
81
|
+
if (!group) return [];
|
|
82
|
+
const daemonSessions = sessions ?? await this.getDaemonSessions();
|
|
83
|
+
// A disconnected daemon cannot certify that an apparently leased tab is orphaned.
|
|
84
|
+
if (!Array.isArray(daemonSessions)) return [];
|
|
85
|
+
const ownedTabIds = new Set((daemonSessions || []).flatMap(session => [session.tabId, ...(session.tabIds || [])])
|
|
86
|
+
.map(Number).filter(Number.isInteger));
|
|
87
|
+
const tabs = await this.groupTabs(group.id);
|
|
88
|
+
const candidates = tabs.filter(tab => {
|
|
89
|
+
const url = this.tabUrl(tab);
|
|
90
|
+
const meta = this.parseManagedUrl(url);
|
|
91
|
+
return url === 'about:blank' ||
|
|
92
|
+
(meta?.workspace === normalizedName && meta.role === 'lease' && meta.tabId === String(tab.id));
|
|
93
|
+
});
|
|
94
|
+
const candidateIds = new Set(candidates.map(tab => tab.id));
|
|
95
|
+
for (const key of this.orphanObserved.keys()) {
|
|
96
|
+
const [workspace, id] = key.split('\0');
|
|
97
|
+
if (workspace === normalizedName && !candidateIds.has(Number(id))) this.orphanObserved.delete(key);
|
|
98
|
+
}
|
|
99
|
+
await this.saveOrphanObservations();
|
|
100
|
+
const recovered = [];
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
const key = `${normalizedName}\0${candidate.id}`;
|
|
103
|
+
if (ownedTabIds.has(candidate.id) || this.inFlightTabIds.has(candidate.id)) {
|
|
104
|
+
this.orphanObserved.delete(key);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const firstSeen = this.orphanObserved.get(key) ?? this.now();
|
|
108
|
+
this.orphanObserved.set(key, firstSeen);
|
|
109
|
+
await this.saveOrphanObservations();
|
|
110
|
+
if (this.now() - firstSeen < graceMs) continue;
|
|
111
|
+
|
|
112
|
+
// Re-read both Chrome ownership and the daemon's current session list
|
|
113
|
+
// immediately before navigating. The workspace lock excludes acquisition.
|
|
114
|
+
const current = await this.chrome.tabs.get(candidate.id).catch(() => null);
|
|
115
|
+
const currentUrl = this.tabUrl(current);
|
|
116
|
+
const currentMeta = this.parseManagedUrl(currentUrl);
|
|
117
|
+
const stillCandidate = current?.groupId === group.id && (currentUrl === 'about:blank' ||
|
|
118
|
+
(currentMeta?.workspace === normalizedName && currentMeta.role === 'lease' && currentMeta.tabId === String(current.id)));
|
|
119
|
+
const latestSessions = await this.getDaemonSessions();
|
|
120
|
+
if (!Array.isArray(latestSessions)) continue;
|
|
121
|
+
if (!stillCandidate || latestSessions.some(session =>
|
|
122
|
+
Number(session.tabId) === candidate.id || (session.tabIds || []).some(tabId => Number(tabId) === candidate.id)) ||
|
|
123
|
+
this.inFlightTabIds.has(candidate.id)) {
|
|
124
|
+
if (!stillCandidate) this.orphanObserved.delete(key);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
await this.chrome.tabs.update(candidate.id, { url: this.idleUrl(normalizedName), active: false });
|
|
128
|
+
const idle = await this.waitForIdleTab(candidate.id, normalizedName);
|
|
129
|
+
this.markOwned(idle.id);
|
|
130
|
+
this.orphanObserved.delete(key);
|
|
131
|
+
await this.saveOrphanObservations();
|
|
132
|
+
recovered.push({ workspace: normalizedName, tabId: candidate.id, recovered: true });
|
|
133
|
+
}
|
|
134
|
+
return recovered;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async recoverConfiguredOrphans() {
|
|
138
|
+
const config = await this.loadConfig();
|
|
139
|
+
const recovered = [];
|
|
140
|
+
for (const name of Object.keys(config.workspaces || {}).sort()) {
|
|
141
|
+
recovered.push(...await this.workspaceLock(name).run(() => this.recoverOrphanedLeases(name)));
|
|
142
|
+
}
|
|
143
|
+
return recovered;
|
|
144
|
+
}
|
|
145
|
+
|
|
32
146
|
isOwnedTab(tab) {
|
|
33
147
|
const tabId = Number(tab?.id);
|
|
34
148
|
if (Number.isInteger(tabId) && this.ownedTabIds.has(tabId)) return true;
|
|
@@ -550,6 +664,7 @@ export class WorkspaceManager {
|
|
|
550
664
|
|
|
551
665
|
async acquireIdentity(name) {
|
|
552
666
|
const normalizedName = normalizeWorkspaceName(name);
|
|
667
|
+
return await this.workspaceLock(normalizedName).run(async () => {
|
|
553
668
|
// Leasing may allocate a Tab inside an existing Workspace, but must never
|
|
554
669
|
// provision a new Workspace as a side effect. Only app-owned creation can.
|
|
555
670
|
const { entry } = await this.configuredWorkspace(normalizedName);
|
|
@@ -558,6 +673,7 @@ export class WorkspaceManager {
|
|
|
558
673
|
// Previously provisioned workspaces may restore a missing physical group
|
|
559
674
|
// from their durable configuration; this never creates a new name.
|
|
560
675
|
const ready = await this.ensureWorkspace(normalizedName);
|
|
676
|
+
await this.recoverOrphanedLeases(normalizedName);
|
|
561
677
|
const tabs = await this.groupTabs(ready.groupId);
|
|
562
678
|
const { idle, leased } = this.classify(normalizedName, tabs);
|
|
563
679
|
if (!idle.length && leased.length >= ready.poolSize) {
|
|
@@ -576,9 +692,12 @@ export class WorkspaceManager {
|
|
|
576
692
|
}
|
|
577
693
|
|
|
578
694
|
this.markOwned(tab.id);
|
|
695
|
+
this.inFlightTabIds.add(tab.id);
|
|
579
696
|
const identityTitle = this.identityTitle(tab.id);
|
|
580
697
|
const identityUrl = this.identityUrl(normalizedName, tab.id);
|
|
581
|
-
|
|
698
|
+
let updated;
|
|
699
|
+
try { updated = await this.chrome.tabs.update(tab.id, { url: identityUrl, active: false }); }
|
|
700
|
+
finally { this.inFlightTabIds.delete(tab.id); }
|
|
582
701
|
await this.chrome.tabGroups.update(ready.groupId, { title: normalizedName, collapsed: true });
|
|
583
702
|
return {
|
|
584
703
|
workspace: normalizedName,
|
|
@@ -589,6 +708,7 @@ export class WorkspaceManager {
|
|
|
589
708
|
idleUrl: this.idleUrl(normalizedName),
|
|
590
709
|
active: Boolean(updated.active),
|
|
591
710
|
};
|
|
711
|
+
});
|
|
592
712
|
}
|
|
593
713
|
|
|
594
714
|
async acquire(name, url) {
|
|
@@ -597,6 +717,7 @@ export class WorkspaceManager {
|
|
|
597
717
|
if (!["http:", "https:", "chrome-extension:"].includes(parsed.protocol)) {
|
|
598
718
|
throw new Error("Workspace acquire requires an http(s) or chrome-extension URL");
|
|
599
719
|
}
|
|
720
|
+
return await this.workspaceLock(normalizedName).run(async () => {
|
|
600
721
|
|
|
601
722
|
// Leasing may allocate a Tab inside an existing Workspace, but must never
|
|
602
723
|
// provision a new Workspace as a side effect. Only app-owned creation can.
|
|
@@ -606,6 +727,7 @@ export class WorkspaceManager {
|
|
|
606
727
|
// Previously provisioned workspaces may restore a missing physical group
|
|
607
728
|
// from their durable configuration; this never creates a new name.
|
|
608
729
|
const ready = await this.ensureWorkspace(normalizedName);
|
|
730
|
+
await this.recoverOrphanedLeases(normalizedName);
|
|
609
731
|
const tabs = await this.groupTabs(ready.groupId);
|
|
610
732
|
const { idle, leased } = this.classify(normalizedName, tabs);
|
|
611
733
|
if (!idle.length && leased.length >= ready.poolSize) {
|
|
@@ -634,6 +756,7 @@ export class WorkspaceManager {
|
|
|
634
756
|
url: updated.url || parsed.href,
|
|
635
757
|
active: Boolean(updated.active),
|
|
636
758
|
};
|
|
759
|
+
});
|
|
637
760
|
}
|
|
638
761
|
|
|
639
762
|
async release(name, tabId) {
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -615,6 +615,8 @@ let controlPollActive = false;
|
|
|
615
615
|
function visibleSessions() {
|
|
616
616
|
return [...sessions.values()].map(s => ({
|
|
617
617
|
session_id:s.session_id,workspace:s.workspace,last_used_at:s.last_used_at,
|
|
618
|
+
tabId:s.leasedTabs.get(s.targetId) ?? s.leasedTabs.get(s.rootTargetId) ?? null,
|
|
619
|
+
tabIds:[...s.leasedTabs.values()],
|
|
618
620
|
}));
|
|
619
621
|
}
|
|
620
622
|
async function pollExtensionControl() {
|