@logicseek/hello-cc 0.1.6 → 0.1.7
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/CHANGELOG.md +55 -0
- package/README.md +21 -3
- package/README.zh-CN.md +21 -3
- package/assets/screenshots/web-console.png +0 -0
- package/bin/hcc.mjs +409 -96
- package/lib/cli-runtime.mjs +2 -1
- package/lib/core/coordination/tasks.mjs +17 -1
- package/lib/core/coordination/timeline.mjs +10 -1
- package/lib/core/peers/identity.mjs +10 -2
- package/lib/web/peer-actions.mjs +42 -6
- package/lib/web/ui-template.mjs +5 -3
- package/package.json +1 -1
- package/scripts/regression.mjs +422 -51
package/lib/cli-runtime.mjs
CHANGED
|
@@ -8,7 +8,8 @@ export function createContext(global = {}, deps = {}) {
|
|
|
8
8
|
const detectRoot = deps.detectRoot;
|
|
9
9
|
if (typeof detectRoot !== 'function') throw new TypeError('createContext requires deps.detectRoot');
|
|
10
10
|
const root = detectRoot(cwd, global.root);
|
|
11
|
-
const
|
|
11
|
+
const envDb = global.root ? null : process.env.HCC_DB;
|
|
12
|
+
const dbPath = path.resolve(global.db || envDb || projectDbPath(root));
|
|
12
13
|
return { cwd, root, dbPath, json: global.json, explicitRoot: Boolean(global.root || process.env.HCC_ROOT) };
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -19,6 +19,16 @@ export function createTaskStore(deps = {}) {
|
|
|
19
19
|
const sendMessage = typeof deps.sendMessage === 'function' ? deps.sendMessage : noopMessage;
|
|
20
20
|
const activePeerTtl = Number(deps.activePeerTtl || DEFAULT_ACTIVE_PEER_TTL);
|
|
21
21
|
|
|
22
|
+
function taskOwnedError(peer, row, action) {
|
|
23
|
+
return new CliError('TASK_OWNED', `Task #${row.id} is owned by ${row.owner}`, {
|
|
24
|
+
owner: row.owner,
|
|
25
|
+
task_id: row.id,
|
|
26
|
+
attempted_by: peer,
|
|
27
|
+
action,
|
|
28
|
+
notify_owner: true
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
function claimTaskRowsForPeer(db, peer, ids, { force = false, source = null } = {}) {
|
|
23
33
|
return tx(db, () => {
|
|
24
34
|
const tasks = [];
|
|
@@ -26,7 +36,7 @@ export function createTaskStore(deps = {}) {
|
|
|
26
36
|
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
27
37
|
if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
|
|
28
38
|
if (row.owner && row.owner !== peer && !force) {
|
|
29
|
-
throw
|
|
39
|
+
throw taskOwnedError(peer, row, 'claim');
|
|
30
40
|
}
|
|
31
41
|
if (row.assignee && row.assignee !== peer && !force) {
|
|
32
42
|
throw new CliError('TASK_ASSIGNED', `Task #${id} is assigned to ${row.assignee}`, { assignee: row.assignee });
|
|
@@ -211,7 +221,13 @@ export function createTaskStore(deps = {}) {
|
|
|
211
221
|
});
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
function assertTaskOwnerForMutation(db, peer, row, action) {
|
|
225
|
+
if (!row.owner || row.owner === peer) return;
|
|
226
|
+
throw taskOwnedError(peer, row, action);
|
|
227
|
+
}
|
|
228
|
+
|
|
214
229
|
return {
|
|
230
|
+
assertTaskOwnerForMutation,
|
|
215
231
|
claimNextTasksForPeer,
|
|
216
232
|
claimTaskRowsForPeer,
|
|
217
233
|
queryOpenTasks,
|
|
@@ -25,7 +25,7 @@ export function taskParticipants(task) {
|
|
|
25
25
|
|
|
26
26
|
export function payloadParticipants(payload) {
|
|
27
27
|
const values = [];
|
|
28
|
-
for (const key of ['peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
|
|
28
|
+
for (const key of ['peer', 'actor_peer', 'target_peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
|
|
29
29
|
if (payload?.[key] && payload[key] !== 'all') values.push(payload[key]);
|
|
30
30
|
}
|
|
31
31
|
return uniqueList(values);
|
|
@@ -59,13 +59,22 @@ const TIMELINE_EVENT_ALLOW = new Set([
|
|
|
59
59
|
'peer.registered',
|
|
60
60
|
'peer.joined',
|
|
61
61
|
'peer.auto_joined',
|
|
62
|
+
'peer.start.requested',
|
|
63
|
+
'peer.attach.requested',
|
|
64
|
+
'peer.stop.requested',
|
|
62
65
|
'peer.stopped',
|
|
66
|
+
'peer.restarted',
|
|
63
67
|
'provider.session.merged',
|
|
68
|
+
'provider.session.rebound',
|
|
64
69
|
'web.session.started',
|
|
65
70
|
'web.session.exited',
|
|
71
|
+
'web.session.stop_requested',
|
|
66
72
|
'tmux.session.attached',
|
|
67
73
|
'tmux.session.detached',
|
|
68
74
|
'tmux.session.exited',
|
|
75
|
+
'tmux.session.restarted',
|
|
76
|
+
'tmux.session.rebound',
|
|
77
|
+
'tmux.session.gc',
|
|
69
78
|
'run.session.started',
|
|
70
79
|
'run.session.exited'
|
|
71
80
|
]);
|
|
@@ -43,8 +43,16 @@ export function autoPeerId(ctx, kindHint = 'shell', observation = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function resolveCurrentPeer(ctx, opts = {}, key = 'peer', kindHint = 'shell', observation = {}) {
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const explicit = opts[key] || '';
|
|
47
|
+
const envPeer = observation.env?.HCC_PEER || '';
|
|
48
|
+
if (envPeer) {
|
|
49
|
+
return {
|
|
50
|
+
id: envPeer,
|
|
51
|
+
auto: false,
|
|
52
|
+
corrected_from: explicit && explicit !== envPeer ? explicit : null
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (explicit) return { id: explicit, auto: false, corrected_from: null };
|
|
48
56
|
return { id: autoPeerId(ctx, kindHint, observation), auto: true };
|
|
49
57
|
}
|
|
50
58
|
|
package/lib/web/peer-actions.mjs
CHANGED
|
@@ -30,6 +30,7 @@ function requireFn(value, name) {
|
|
|
30
30
|
export function createWebPeerActions(deps = {}) {
|
|
31
31
|
const activePeerTtl = Number(deps.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
|
|
32
32
|
const addEvent = requireFn(deps.addEvent, 'addEvent');
|
|
33
|
+
const assertTaskOwnerForMutation = requireFn(deps.assertTaskOwnerForMutation, 'assertTaskOwnerForMutation');
|
|
33
34
|
const claimNextTasksForPeer = requireFn(deps.claimNextTasksForPeer, 'claimNextTasksForPeer');
|
|
34
35
|
const connect = requireFn(deps.connect, 'connect');
|
|
35
36
|
const defaultLockTtl = Number(deps.defaultLockTtl ?? DEFAULT_LOCK_TTL);
|
|
@@ -44,7 +45,32 @@ export function createWebPeerActions(deps = {}) {
|
|
|
44
45
|
const tx = requireFn(deps.tx, 'tx');
|
|
45
46
|
const upsertPeer = requireFn(deps.upsertPeer, 'upsertPeer');
|
|
46
47
|
|
|
48
|
+
function webMutationPeer(peer, input = {}) {
|
|
49
|
+
const actorPeer = String(input.actorPeer || input.actor_peer || '').trim();
|
|
50
|
+
if (!actorPeer) {
|
|
51
|
+
throw new CliError('PEER_IDENTITY_REQUIRED', 'Web peer mutations require a managed session identity');
|
|
52
|
+
}
|
|
53
|
+
if (actorPeer !== peer) {
|
|
54
|
+
throw new CliError('PEER_IDENTITY_MISMATCH', `Web peer action for ${peer} cannot run as ${actorPeer}`, {
|
|
55
|
+
peer,
|
|
56
|
+
actor_peer: actorPeer
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return actorPeer;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function webAuditPayload(peer, input = {}, extra = {}) {
|
|
63
|
+
const actorPeer = String(input.actorPeer || input.actor_peer || peer).trim() || peer;
|
|
64
|
+
return {
|
|
65
|
+
...extra,
|
|
66
|
+
source: 'web',
|
|
67
|
+
actor_peer: actorPeer,
|
|
68
|
+
target_peer: peer
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
function webPeerRegister(projectCtx, peer, input = {}) {
|
|
73
|
+
webMutationPeer(peer, input);
|
|
48
74
|
const db = connect(projectCtx);
|
|
49
75
|
const row = {
|
|
50
76
|
id: peer,
|
|
@@ -58,7 +84,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
58
84
|
};
|
|
59
85
|
try {
|
|
60
86
|
upsertPeer(db, row);
|
|
61
|
-
addEvent(db, 'peer.registered', peer, null,
|
|
87
|
+
addEvent(db, 'peer.registered', peer, null, webAuditPayload(peer, input, row));
|
|
62
88
|
} finally {
|
|
63
89
|
db.close();
|
|
64
90
|
}
|
|
@@ -66,6 +92,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
66
92
|
}
|
|
67
93
|
|
|
68
94
|
function webPeerHeartbeat(projectCtx, peer, input = {}) {
|
|
95
|
+
webMutationPeer(peer, input);
|
|
69
96
|
const ttl = intOpt(input, 'ttl', defaultLockTtl);
|
|
70
97
|
const status = input.status || null;
|
|
71
98
|
const renewLocks = input['renew-locks'] !== false && input.renew_locks !== false;
|
|
@@ -80,7 +107,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
80
107
|
WHERE owner = ? AND expires_at > ?
|
|
81
108
|
`).run(t + ttl, peer, t).changes;
|
|
82
109
|
}
|
|
83
|
-
addEvent(db, 'peer.heartbeat', peer, null, { status, renewed
|
|
110
|
+
addEvent(db, 'peer.heartbeat', peer, null, webAuditPayload(peer, input, { status, renewed }));
|
|
84
111
|
} finally {
|
|
85
112
|
db.close();
|
|
86
113
|
}
|
|
@@ -93,6 +120,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
93
120
|
}
|
|
94
121
|
|
|
95
122
|
function webPeerTaskNext(projectCtx, peer, input = {}) {
|
|
123
|
+
webMutationPeer(peer, input);
|
|
96
124
|
const db = connect(projectCtx);
|
|
97
125
|
try {
|
|
98
126
|
touchPeer(db, peer, 'working');
|
|
@@ -117,6 +145,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
117
145
|
}
|
|
118
146
|
|
|
119
147
|
function webPeerTaskTakeover(projectCtx, peer, input = {}) {
|
|
148
|
+
webMutationPeer(peer, input);
|
|
120
149
|
const id = intOpt(input, 'id', intOpt({ id: input.task }, 'id'));
|
|
121
150
|
if (!id) throw new CliError('BAD_REQUEST', 'task id required');
|
|
122
151
|
const reason = required(input, 'reason');
|
|
@@ -133,6 +162,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
133
162
|
}
|
|
134
163
|
|
|
135
164
|
function webPeerLockAcquire(projectCtx, peer, input = {}) {
|
|
165
|
+
webMutationPeer(peer, input);
|
|
136
166
|
const requested = scopedLockResource(required(input, 'resource'), input.scope);
|
|
137
167
|
const ttl = intOpt(input, 'ttl', defaultLockTtl);
|
|
138
168
|
const taskId = intOpt(input, 'task', intOpt(input, 'task_id', null));
|
|
@@ -142,6 +172,11 @@ export function createWebPeerActions(deps = {}) {
|
|
|
142
172
|
touchPeer(db, peer, 'working');
|
|
143
173
|
const lock = tx(db, () => {
|
|
144
174
|
const t = now();
|
|
175
|
+
if (taskId) {
|
|
176
|
+
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
|
177
|
+
if (!task) throw new CliError('NOT_FOUND', `Task #${taskId} does not exist`);
|
|
178
|
+
assertTaskOwnerForMutation(db, peer, task, 'lock-acquire');
|
|
179
|
+
}
|
|
145
180
|
const activeLocks = db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
|
|
146
181
|
const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
|
|
147
182
|
if (conflict) {
|
|
@@ -166,7 +201,7 @@ export function createWebPeerActions(deps = {}) {
|
|
|
166
201
|
reason = excluded.reason,
|
|
167
202
|
expires_at = excluded.expires_at
|
|
168
203
|
`).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
|
|
169
|
-
addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null
|
|
204
|
+
addEvent(db, 'lock.acquired', peer, taskId, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null }));
|
|
170
205
|
return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
|
|
171
206
|
});
|
|
172
207
|
return { peer, lock, summary: `locked ${lockLabel(lock)} by ${lock.owner} until ${iso(lock.expires_at)}` };
|
|
@@ -176,19 +211,20 @@ export function createWebPeerActions(deps = {}) {
|
|
|
176
211
|
}
|
|
177
212
|
|
|
178
213
|
function webPeerLockRelease(projectCtx, peer, input = {}) {
|
|
214
|
+
webMutationPeer(peer, input);
|
|
179
215
|
const requested = scopedLockResource(required(input, 'resource'), input.scope);
|
|
180
|
-
const force =
|
|
216
|
+
const force = false;
|
|
181
217
|
const db = connect(projectCtx);
|
|
182
218
|
try {
|
|
183
219
|
touchPeer(db, peer, null);
|
|
184
220
|
const result = tx(db, () => {
|
|
185
221
|
const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
|
|
186
222
|
if (!existing) return { released: false, ...requested };
|
|
187
|
-
if (existing.owner !== peer
|
|
223
|
+
if (existing.owner !== peer) {
|
|
188
224
|
throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
|
189
225
|
}
|
|
190
226
|
db.prepare('DELETE FROM locks WHERE resource = ?').run(requested.resource);
|
|
191
|
-
addEvent(db, 'lock.released', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force
|
|
227
|
+
addEvent(db, 'lock.released', peer, existing.task_id || null, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force }));
|
|
192
228
|
return { released: true, ...requested };
|
|
193
229
|
});
|
|
194
230
|
return { peer, result, summary: result.released ? `released ${lockLabel(result)}` : `no lock for ${lockLabel(result)}` };
|
package/lib/web/ui-template.mjs
CHANGED
|
@@ -1693,7 +1693,7 @@ export function webIndexHtml() {
|
|
|
1693
1693
|
if (!body) return;
|
|
1694
1694
|
await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
|
|
1695
1695
|
method: 'POST',
|
|
1696
|
-
body: JSON.stringify({ body
|
|
1696
|
+
body: JSON.stringify({ body })
|
|
1697
1697
|
});
|
|
1698
1698
|
document.getElementById('detMsg').value = '';
|
|
1699
1699
|
await refreshDetectedState();
|
|
@@ -1733,7 +1733,8 @@ export function webIndexHtml() {
|
|
|
1733
1733
|
const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
|
|
1734
1734
|
return {
|
|
1735
1735
|
session,
|
|
1736
|
-
peerId: sessionPeerId(session) || active
|
|
1736
|
+
peerId: sessionPeerId(session) || active,
|
|
1737
|
+
actionToken: session.action_token || ''
|
|
1737
1738
|
};
|
|
1738
1739
|
}
|
|
1739
1740
|
|
|
@@ -1807,12 +1808,13 @@ export function webIndexHtml() {
|
|
|
1807
1808
|
async function runPeerAction(action) {
|
|
1808
1809
|
const info = activePeerInfo();
|
|
1809
1810
|
if (!info) return;
|
|
1811
|
+
const readOnly = ['status', 'state', 'inbox'].includes(action);
|
|
1810
1812
|
const payload = action === 'register'
|
|
1811
1813
|
? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
|
|
1812
1814
|
: action === 'heartbeat'
|
|
1813
1815
|
? { renew_locks: true }
|
|
1814
1816
|
: {};
|
|
1815
|
-
|
|
1817
|
+
if (!readOnly) payload.action_token = info.actionToken;
|
|
1816
1818
|
const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
|
|
1817
1819
|
? {}
|
|
1818
1820
|
: { method: 'POST', body: JSON.stringify(payload) });
|