@logicseek/hello-cc 0.1.4 → 0.1.6
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 +126 -0
- package/bin/hcc.mjs +2739 -6482
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +3 -1
- package/docs/architecture-split-review.md +217 -0
- package/docs/architecture.md +385 -0
- package/docs/architecture.zh-CN.md +364 -0
- package/docs/commands.md +21 -9
- package/docs/commands.zh-CN.md +19 -9
- package/docs/layout-split-stack-audit.md +298 -0
- package/lib/automation.mjs +8 -0
- package/lib/cli-args.mjs +89 -0
- package/lib/cli-runtime.mjs +44 -0
- package/lib/coordination-state.mjs +218 -0
- package/lib/core/coordination/automation.mjs +222 -0
- package/lib/core/coordination/locks.mjs +46 -0
- package/lib/core/coordination/messages.mjs +116 -0
- package/lib/core/coordination/tasks.mjs +224 -0
- package/lib/core/coordination/teams.mjs +73 -0
- package/lib/core/coordination/timeline.mjs +187 -0
- package/lib/core/peers/bindings.mjs +83 -0
- package/lib/core/peers/format.mjs +13 -0
- package/lib/core/peers/identity.mjs +53 -0
- package/lib/core/peers/liveness.mjs +90 -0
- package/lib/core/peers/reconcile.mjs +238 -0
- package/lib/core/peers/session.mjs +14 -0
- package/lib/core/sessions/launch.mjs +89 -0
- package/lib/db/schema.mjs +324 -0
- package/lib/db/stores/peers.mjs +231 -0
- package/lib/db-schema.mjs +7 -0
- package/lib/errors.mjs +1 -0
- package/lib/format.mjs +39 -0
- package/lib/guidance.mjs +10 -2
- package/lib/handoff.mjs +26 -0
- package/lib/help.mjs +3 -0
- package/lib/integrations/hooks.mjs +171 -0
- package/lib/integrations/peers/identity.mjs +208 -0
- package/lib/integrations/peers/processes.mjs +151 -0
- package/lib/integrations/providers.mjs +282 -0
- package/lib/integrations/shims/script.mjs +384 -0
- package/lib/integrations/shims.mjs +106 -0
- package/lib/json-file.mjs +1 -0
- package/lib/locks.mjs +10 -0
- package/lib/messages.mjs +3 -0
- package/lib/package-meta.mjs +6 -24
- package/lib/peer-bindings.mjs +14 -0
- package/lib/peer-format.mjs +4 -0
- package/lib/peer-identity.mjs +15 -0
- package/lib/project-context.mjs +6 -0
- package/lib/project-registry.mjs +7 -0
- package/lib/provider-commands.mjs +13 -0
- package/lib/release/package-meta.mjs +31 -0
- package/lib/release/release-notes.mjs +40 -0
- package/lib/release-notes.mjs +6 -40
- package/lib/runtime/client.mjs +42 -0
- package/lib/runtime/paths.mjs +45 -0
- package/lib/runtime/project-context.mjs +32 -0
- package/lib/runtime/projects.mjs +76 -0
- package/lib/runtime/state.mjs +128 -0
- package/lib/runtime-client.mjs +3 -0
- package/lib/runtime-paths.mjs +11 -0
- package/lib/runtime-state.mjs +11 -0
- package/lib/session-launch.mjs +17 -0
- package/lib/setup.mjs +18 -765
- package/lib/shared/errors.mjs +7 -0
- package/lib/shared/json-file.mjs +14 -0
- package/lib/shell-path.mjs +69 -0
- package/lib/shim-script.mjs +1 -0
- package/lib/state-render.mjs +5 -0
- package/lib/task-cli.mjs +45 -0
- package/lib/task-liveness.mjs +8 -0
- package/lib/task-store.mjs +3 -0
- package/lib/team-planning.mjs +7 -0
- package/lib/terminal/tmux.mjs +372 -0
- package/lib/timeline.mjs +12 -0
- package/lib/tmux.mjs +18 -0
- package/lib/ui/help.mjs +419 -0
- package/lib/ui/state-render.mjs +61 -0
- package/lib/web/http.mjs +55 -0
- package/lib/web/peer-actions.mjs +270 -0
- package/lib/web/runtime.mjs +154 -0
- package/lib/web/ui-template.mjs +2055 -0
- package/lib/web-http.mjs +7 -0
- package/lib/web-peer-actions.mjs +3 -0
- package/lib/web-runtime.mjs +17 -0
- package/lib/web-ui-template.mjs +3 -0
- package/package.json +1 -1
- package/scripts/github-release.mjs +2 -2
- package/scripts/regression.mjs +2738 -22
- package/scripts/release-notes.mjs +2 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { compactText } from '../../format.mjs';
|
|
2
|
+
import { lockLabel } from './locks.mjs';
|
|
3
|
+
|
|
4
|
+
export function parseEventPayload(row) {
|
|
5
|
+
try {
|
|
6
|
+
return row?.payload ? JSON.parse(row.payload) : {};
|
|
7
|
+
} catch {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function uniqueList(values) {
|
|
13
|
+
return [...new Set(values.filter(Boolean).map(String))];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function messageParticipants(message) {
|
|
17
|
+
const participants = [message.sender];
|
|
18
|
+
if (message.recipient && !['all', ''].includes(message.recipient)) participants.push(message.recipient);
|
|
19
|
+
return uniqueList(participants);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function taskParticipants(task) {
|
|
23
|
+
return uniqueList([task.created_by, task.owner, task.assignee]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function payloadParticipants(payload) {
|
|
27
|
+
const values = [];
|
|
28
|
+
for (const key of ['peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
|
|
29
|
+
if (payload?.[key] && payload[key] !== 'all') values.push(payload[key]);
|
|
30
|
+
}
|
|
31
|
+
return uniqueList(values);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function peerMatchesTimelineItem(item, peer) {
|
|
35
|
+
if (!peer) return true;
|
|
36
|
+
if (item.broadcast) return true;
|
|
37
|
+
return (item.peers || []).includes(peer);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function shouldHideTimelineMessage(message) {
|
|
41
|
+
if (message.kind === 'task' && /^Task #\d+ assigned: /.test(message.body || '')) return true;
|
|
42
|
+
if (message.kind === 'handoff' && /^Handoff #\d+: /.test(message.body || '')) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TIMELINE_EVENT_ALLOW = new Set([
|
|
47
|
+
'task.created',
|
|
48
|
+
'task.claimed',
|
|
49
|
+
'task.pending',
|
|
50
|
+
'task.running',
|
|
51
|
+
'task.review',
|
|
52
|
+
'task.blocked',
|
|
53
|
+
'task.done',
|
|
54
|
+
'task.abandoned',
|
|
55
|
+
'task.takeover',
|
|
56
|
+
'team.started',
|
|
57
|
+
'lock.acquired',
|
|
58
|
+
'lock.released',
|
|
59
|
+
'peer.registered',
|
|
60
|
+
'peer.joined',
|
|
61
|
+
'peer.auto_joined',
|
|
62
|
+
'peer.stopped',
|
|
63
|
+
'provider.session.merged',
|
|
64
|
+
'web.session.started',
|
|
65
|
+
'web.session.exited',
|
|
66
|
+
'tmux.session.attached',
|
|
67
|
+
'tmux.session.detached',
|
|
68
|
+
'tmux.session.exited',
|
|
69
|
+
'run.session.started',
|
|
70
|
+
'run.session.exited'
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
export function shouldHideTimelineEvent(event) {
|
|
74
|
+
if (['message.sent', 'message.ack', 'handoff.created', 'lock.renewed'].includes(event.type)) return true;
|
|
75
|
+
if (event.type && event.type.startsWith('hook.')) return true;
|
|
76
|
+
if (event.type && event.type.startsWith('web.session.input')) return true;
|
|
77
|
+
return !TIMELINE_EVENT_ALLOW.has(event.type);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function timelineDirection(message, peer) {
|
|
81
|
+
if (!peer) return message.recipient === 'all' ? 'broadcast' : 'project';
|
|
82
|
+
if (message.sender === peer && message.recipient === peer) return 'self';
|
|
83
|
+
if (message.sender === peer) return 'out';
|
|
84
|
+
if (message.recipient === 'all') return 'broadcast';
|
|
85
|
+
if (message.recipient === peer) return 'in';
|
|
86
|
+
return 'project';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function timelineFromRows({ messages = [], handoffs = [], tasks = [], locks = [], events = [] }, peer = null) {
|
|
90
|
+
const items = [];
|
|
91
|
+
for (const message of messages) {
|
|
92
|
+
if (shouldHideTimelineMessage(message)) continue;
|
|
93
|
+
const item = {
|
|
94
|
+
id: `message:${message.id}`,
|
|
95
|
+
source: 'message',
|
|
96
|
+
source_id: message.id,
|
|
97
|
+
ts: message.created_at,
|
|
98
|
+
actor: message.sender,
|
|
99
|
+
peers: messageParticipants(message),
|
|
100
|
+
task_id: message.task_id || null,
|
|
101
|
+
kind: message.kind || 'note',
|
|
102
|
+
title: `${message.sender} -> ${message.recipient || 'all'}`,
|
|
103
|
+
text: compactText(message.body),
|
|
104
|
+
unread: message.read_at === null || message.read_at === undefined,
|
|
105
|
+
direction: timelineDirection(message, peer),
|
|
106
|
+
thread_id: message.thread_id || message.id,
|
|
107
|
+
reply_to: message.reply_to || null,
|
|
108
|
+
broadcast: !message.recipient || message.recipient === 'all'
|
|
109
|
+
};
|
|
110
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
111
|
+
}
|
|
112
|
+
for (const handoff of handoffs) {
|
|
113
|
+
const item = {
|
|
114
|
+
id: `handoff:${handoff.id}`,
|
|
115
|
+
source: 'handoff',
|
|
116
|
+
source_id: handoff.id,
|
|
117
|
+
ts: handoff.created_at,
|
|
118
|
+
actor: handoff.from_peer,
|
|
119
|
+
peers: uniqueList([handoff.from_peer, handoff.to_peer]),
|
|
120
|
+
task_id: handoff.task_id || null,
|
|
121
|
+
kind: 'handoff',
|
|
122
|
+
title: `handoff ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}`,
|
|
123
|
+
text: compactText(handoff.summary),
|
|
124
|
+
direction: 'project',
|
|
125
|
+
broadcast: !handoff.to_peer,
|
|
126
|
+
tests: handoff.tests || '',
|
|
127
|
+
risks: handoff.risks || ''
|
|
128
|
+
};
|
|
129
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
130
|
+
}
|
|
131
|
+
for (const task of tasks) {
|
|
132
|
+
const item = {
|
|
133
|
+
id: `task:${task.id}`,
|
|
134
|
+
source: 'task',
|
|
135
|
+
source_id: task.id,
|
|
136
|
+
ts: task.updated_at || task.created_at,
|
|
137
|
+
actor: task.owner || task.created_by || task.assignee || '',
|
|
138
|
+
peers: taskParticipants(task),
|
|
139
|
+
task_id: task.id,
|
|
140
|
+
kind: task.status,
|
|
141
|
+
title: `task #${task.id} ${task.status}${task.parent_id ? ` child of #${task.parent_id}` : ''}`,
|
|
142
|
+
text: compactText(task.title),
|
|
143
|
+
direction: 'project'
|
|
144
|
+
};
|
|
145
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
146
|
+
}
|
|
147
|
+
for (const lock of locks) {
|
|
148
|
+
const item = {
|
|
149
|
+
id: `lock:${lock.resource}`,
|
|
150
|
+
source: 'lock',
|
|
151
|
+
source_id: lock.resource,
|
|
152
|
+
ts: lock.created_at,
|
|
153
|
+
actor: lock.owner,
|
|
154
|
+
peers: uniqueList([lock.owner]),
|
|
155
|
+
task_id: lock.task_id || null,
|
|
156
|
+
kind: 'active',
|
|
157
|
+
title: `lock ${lockLabel(lock)}`,
|
|
158
|
+
text: compactText(lock.reason || `owner=${lock.owner}`),
|
|
159
|
+
direction: 'project'
|
|
160
|
+
};
|
|
161
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
162
|
+
}
|
|
163
|
+
for (const event of events) {
|
|
164
|
+
if (shouldHideTimelineEvent(event)) continue;
|
|
165
|
+
const payload = parseEventPayload(event);
|
|
166
|
+
const item = {
|
|
167
|
+
id: `event:${event.id}`,
|
|
168
|
+
source: 'event',
|
|
169
|
+
source_id: event.id,
|
|
170
|
+
ts: event.created_at,
|
|
171
|
+
actor: event.actor || '',
|
|
172
|
+
peers: uniqueList([event.actor, ...payloadParticipants(payload)]),
|
|
173
|
+
task_id: event.task_id || null,
|
|
174
|
+
kind: event.type,
|
|
175
|
+
title: event.type,
|
|
176
|
+
text: compactText(payload.summary || payload.reason || payload.title || payload.resource || payload.peer || ''),
|
|
177
|
+
direction: 'project'
|
|
178
|
+
};
|
|
179
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
180
|
+
}
|
|
181
|
+
const order = { message: 10, handoff: 20, task: 30, lock: 40, event: 50 };
|
|
182
|
+
items.sort((a, b) =>
|
|
183
|
+
(a.ts || 0) - (b.ts || 0) ||
|
|
184
|
+
(order[a.source] || 99) - (order[b.source] || 99) ||
|
|
185
|
+
String(a.source_id).localeCompare(String(b.source_id), undefined, { numeric: true }));
|
|
186
|
+
return items.slice(-120);
|
|
187
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { providerSessionParts } from './session.mjs';
|
|
2
|
+
|
|
3
|
+
export function bindingFromDetected(peer, transport = 'detected') {
|
|
4
|
+
const provider = peer.kind || 'other';
|
|
5
|
+
const session = providerSessionParts(peer.resumeId || peer.sessionId || '');
|
|
6
|
+
return {
|
|
7
|
+
peer: peer.peerId || peer.id,
|
|
8
|
+
provider,
|
|
9
|
+
...session,
|
|
10
|
+
resume_mode: peer.resumeId ? 'resume' : (peer.sessionId ? 'detected' : 'unknown'),
|
|
11
|
+
resume_arg: peer.resumeId || null,
|
|
12
|
+
command: peer.command || null,
|
|
13
|
+
transport,
|
|
14
|
+
runtime_session_id: peer.peerId || peer.id
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function peerBindingRuntimeRank(row) {
|
|
19
|
+
if (row?.runtime_target && row.transport === 'tmux') return 50;
|
|
20
|
+
if (row?.runtime_target) return 40;
|
|
21
|
+
if (['tmux', 'web-pty'].includes(row?.transport)) return 30;
|
|
22
|
+
if (row?.transport === 'hcc-run') return 20;
|
|
23
|
+
if (row?.transport === 'hook') return 10;
|
|
24
|
+
if (row?.transport === 'detected') return 5;
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function comparePeerBindings(a, b) {
|
|
29
|
+
return peerBindingRuntimeRank(b) - peerBindingRuntimeRank(a) ||
|
|
30
|
+
Number(b.updated_at || 0) - Number(a.updated_at || 0) ||
|
|
31
|
+
Number(b.created_at || 0) - Number(a.created_at || 0) ||
|
|
32
|
+
String(a.peer || '').localeCompare(String(b.peer || ''));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function bindingHasProviderSession(binding) {
|
|
36
|
+
return Boolean(binding?.provider_session_id || binding?.provider_session_name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function bindingProviderSessionValue(binding) {
|
|
40
|
+
return binding?.provider_session_id || binding?.provider_session_name || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function bindingHasRuntime(binding) {
|
|
44
|
+
return Boolean(binding?.runtime_target) || ['tmux', 'web-pty'].includes(binding?.transport);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function mergeRuntimeBinding(existing, binding) {
|
|
48
|
+
if (!existing || !bindingHasRuntime(existing) || bindingHasRuntime(binding)) return binding;
|
|
49
|
+
return {
|
|
50
|
+
...binding,
|
|
51
|
+
command: existing.command || binding.command || null,
|
|
52
|
+
transport: existing.transport,
|
|
53
|
+
runtime_session_id: existing.runtime_session_id || binding.runtime_session_id || binding.peer,
|
|
54
|
+
runtime_target: existing.runtime_target || null
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function providerCompatible(existing, binding) {
|
|
59
|
+
if (!existing?.provider || !binding?.provider) return true;
|
|
60
|
+
return existing.provider === binding.provider;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function providerSessionResumeMode(mode) {
|
|
64
|
+
return new Set(['detected', 'resume', 'session', 'named']).has(mode);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function mergePeerBinding(existing, binding) {
|
|
68
|
+
let next = mergeRuntimeBinding(existing, binding);
|
|
69
|
+
if (!existing || bindingHasProviderSession(next) || !bindingHasProviderSession(existing)) return next;
|
|
70
|
+
if (!providerCompatible(existing, next)) return next;
|
|
71
|
+
|
|
72
|
+
next = {
|
|
73
|
+
...next,
|
|
74
|
+
provider: next.provider || existing.provider,
|
|
75
|
+
provider_session_id: existing.provider_session_id || null,
|
|
76
|
+
provider_session_name: existing.provider_session_name || null
|
|
77
|
+
};
|
|
78
|
+
if (providerSessionResumeMode(existing.resume_mode) && !providerSessionResumeMode(next.resume_mode)) {
|
|
79
|
+
next.resume_mode = existing.resume_mode;
|
|
80
|
+
next.resume_arg = existing.resume_arg || next.resume_arg || null;
|
|
81
|
+
}
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function sanitizePeerPart(value, fallback = 'peer') {
|
|
4
|
+
const text = String(value || '').toLowerCase()
|
|
5
|
+
.replace(/[^a-z0-9._-]/g, '-')
|
|
6
|
+
.replace(/^-+|-+$/g, '')
|
|
7
|
+
.slice(0, 32);
|
|
8
|
+
return text || fallback;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function shortHash(value) {
|
|
12
|
+
return createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { sanitizePeerPart, shortHash } from './format.mjs';
|
|
2
|
+
import { providerSessionPeerId } from './session.mjs';
|
|
3
|
+
|
|
4
|
+
export { sanitizePeerPart, shortHash } from './format.mjs';
|
|
5
|
+
|
|
6
|
+
export function autoPeerProviderSession(_kind, observation = {}) {
|
|
7
|
+
return {
|
|
8
|
+
sessionId: observation.sessionId || '',
|
|
9
|
+
resumeId: observation.resumeId || null,
|
|
10
|
+
ancestor: observation.ancestor || null
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function autoPeerSessionId(kind, observation = {}) {
|
|
15
|
+
return autoPeerProviderSession(kind, observation).sessionId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function autoPeerResumeId(kind, observation = {}) {
|
|
19
|
+
return autoPeerProviderSession(kind, observation).resumeId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function autoPeerKind(kindHint = 'shell', observation = {}) {
|
|
23
|
+
return observation.kind || kindHint || 'shell';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function autoPeerBasis(kind = null, observation = {}) {
|
|
27
|
+
if (observation.basis) return observation.basis;
|
|
28
|
+
const ancestor = observation.ancestor || null;
|
|
29
|
+
if (ancestor && (!kind || ancestor.kind === kind)) return `cli:${ancestor.kind}:${ancestor.pid}`;
|
|
30
|
+
if (observation.tmuxPane) return `tmux:${observation.tmuxPane}`;
|
|
31
|
+
if (observation.tty) return `tty:${observation.tty}`;
|
|
32
|
+
return `ppid:${observation.ppid ?? 0}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function autoPeerId(ctx, kindHint = 'shell', observation = {}) {
|
|
36
|
+
const kind = autoPeerKind(kindHint, observation);
|
|
37
|
+
const { sessionId, resumeId } = autoPeerProviderSession(kind, observation);
|
|
38
|
+
const providerId = resumeId || sessionId;
|
|
39
|
+
if (providerId) return providerSessionPeerId(kind, providerId);
|
|
40
|
+
|
|
41
|
+
const basis = autoPeerBasis(kind, observation);
|
|
42
|
+
return `${kind}-${shortHash(`${ctx.root}:${basis}`)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveCurrentPeer(ctx, opts = {}, key = 'peer', kindHint = 'shell', observation = {}) {
|
|
46
|
+
if (opts[key]) return { id: opts[key], auto: false };
|
|
47
|
+
if (observation.env?.HCC_PEER) return { id: observation.env.HCC_PEER, auto: false };
|
|
48
|
+
return { id: autoPeerId(ctx, kindHint, observation), auto: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function currentPeer(ctx, opts = {}, key = 'peer', kindHint = 'shell', observation = {}) {
|
|
52
|
+
return resolveCurrentPeer(ctx, opts, key, kindHint, observation).id;
|
|
53
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
2
|
+
|
|
3
|
+
function nowSec() {
|
|
4
|
+
return Math.floor(Date.now() / 1000);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function taskRelatedLocks(task, locks) {
|
|
8
|
+
const taskId = Number(task?.id || 0);
|
|
9
|
+
const owner = task?.owner || '';
|
|
10
|
+
return (locks || []).filter((lock) => {
|
|
11
|
+
if (taskId && Number(lock.task_id || 0) === taskId) return true;
|
|
12
|
+
return Boolean(owner && lock.owner === owner);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
|
|
17
|
+
const owner = task?.owner || null;
|
|
18
|
+
const relatedLocks = taskRelatedLocks(task, locks);
|
|
19
|
+
if (!owner) {
|
|
20
|
+
return {
|
|
21
|
+
owner_known: false,
|
|
22
|
+
owner_active: null,
|
|
23
|
+
owner_stale: false,
|
|
24
|
+
owner_age_sec: null,
|
|
25
|
+
related_lock_count: relatedLocks.length,
|
|
26
|
+
takeover_ready: false
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const ownerRow = (peers || []).find((row) => row.id === owner) || null;
|
|
30
|
+
const ownerAge = ownerRow
|
|
31
|
+
? Number(ownerRow.age_sec ?? (t - Number(ownerRow.last_seen_at || 0)))
|
|
32
|
+
: null;
|
|
33
|
+
const ownerActive = Boolean(ownerRow && Number.isFinite(ownerAge) && ownerAge <= activePeerTtl);
|
|
34
|
+
const ownerStale = !ownerActive;
|
|
35
|
+
const takeoverStatus = ['claimed', 'running', 'review', 'blocked'].includes(task.status);
|
|
36
|
+
return {
|
|
37
|
+
owner_known: Boolean(ownerRow),
|
|
38
|
+
owner_active: ownerActive,
|
|
39
|
+
owner_stale: ownerStale,
|
|
40
|
+
owner_age_sec: Number.isFinite(ownerAge) ? ownerAge : null,
|
|
41
|
+
related_lock_count: relatedLocks.length,
|
|
42
|
+
takeover_ready: Boolean(takeoverStatus && ownerStale && relatedLocks.length === 0)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function annotateTasksWithLiveness(tasks, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
|
|
47
|
+
return (tasks || []).map((task) => ({
|
|
48
|
+
...task,
|
|
49
|
+
...taskOwnerLiveness(task, peers, locks, t, activePeerTtl)
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function taskOwnerStateText(task) {
|
|
54
|
+
if (!task?.owner) return '';
|
|
55
|
+
if (task.owner_stale) {
|
|
56
|
+
if (task.takeover_ready) return 'stale/no-lock';
|
|
57
|
+
const locks = Number(task.related_lock_count || 0);
|
|
58
|
+
return locks ? `stale/locks=${locks}` : 'stale';
|
|
59
|
+
}
|
|
60
|
+
if (task.owner_active) return 'active';
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function summarizeTask(task) {
|
|
65
|
+
if (!task) return null;
|
|
66
|
+
return {
|
|
67
|
+
id: task.id,
|
|
68
|
+
title: task.title,
|
|
69
|
+
status: task.status,
|
|
70
|
+
owner: task.owner || null,
|
|
71
|
+
assignee: task.assignee || null,
|
|
72
|
+
parent_id: task.parent_id || null,
|
|
73
|
+
team_role: task.team_role || null,
|
|
74
|
+
priority: task.priority,
|
|
75
|
+
owner_active: task.owner_active ?? null,
|
|
76
|
+
owner_stale: Boolean(task.owner_stale),
|
|
77
|
+
owner_age_sec: task.owner_age_sec ?? null,
|
|
78
|
+
related_lock_count: Number(task.related_lock_count || 0),
|
|
79
|
+
takeover_ready: Boolean(task.takeover_ready)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatOpenTaskLine(task) {
|
|
84
|
+
const parts = [`#${task.id}`, task.status];
|
|
85
|
+
if (task.owner) parts.push(`owner=${task.owner}`);
|
|
86
|
+
if (task.assignee) parts.push(`assignee=${task.assignee}`);
|
|
87
|
+
const ownerState = taskOwnerStateText(task);
|
|
88
|
+
if (ownerState) parts.push(`owner_state=${ownerState}`);
|
|
89
|
+
return `${parts.join(' ')}: ${task.title}`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { providerSessionParts } from './session.mjs';
|
|
3
|
+
import {
|
|
4
|
+
bindingHasProviderSession,
|
|
5
|
+
bindingProviderSessionValue
|
|
6
|
+
} from './bindings.mjs';
|
|
7
|
+
|
|
8
|
+
function defaultNow() {
|
|
9
|
+
return Math.floor(Date.now() / 1000);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function noopEvent() {}
|
|
13
|
+
|
|
14
|
+
function normalizedRoot(root) {
|
|
15
|
+
return root ? path.resolve(root) : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runtimePaneMatches(row, pane) {
|
|
19
|
+
return Boolean(row?.runtime_target && pane?.pane && row.runtime_target === pane.pane);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runtimePidMatches(row, pane) {
|
|
23
|
+
return Boolean(row?.pid && pane?.pid && Number(row.pid) === Number(pane.pid));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runtimeCwdMatches(root, row, pane) {
|
|
27
|
+
const paneCwd = normalizedRoot(pane?.cwd);
|
|
28
|
+
if (!paneCwd) return false;
|
|
29
|
+
const worktree = normalizedRoot(row?.worktree);
|
|
30
|
+
const projectRoot = normalizedRoot(root);
|
|
31
|
+
return Boolean((worktree && paneCwd === worktree) || (projectRoot && paneCwd === projectRoot));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function commandEvidence(row) {
|
|
35
|
+
const value = row?.resume_arg || null;
|
|
36
|
+
if (!value || value === '--last' || String(value).startsWith('%')) return null;
|
|
37
|
+
if (!['resume', 'session', 'detected', 'named'].includes(row?.resume_mode)) return null;
|
|
38
|
+
return {
|
|
39
|
+
provider: row.provider || row.kind || null,
|
|
40
|
+
value,
|
|
41
|
+
source: `binding.${row.resume_mode}`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function processEvidence(row, inspectProcess) {
|
|
46
|
+
if (typeof inspectProcess !== 'function' || !row?.pid) return null;
|
|
47
|
+
const info = inspectProcess(Number(row.pid), row.provider || row.kind || null);
|
|
48
|
+
if (!info?.provider_session) return null;
|
|
49
|
+
return {
|
|
50
|
+
provider: info.kind || row.provider || row.kind || null,
|
|
51
|
+
value: info.provider_session,
|
|
52
|
+
source: info.source || 'process'
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tmuxEvidence(row, pane, opts) {
|
|
57
|
+
const provider = row.provider || row.kind || null;
|
|
58
|
+
const byEvent = eventEvidence(row, opts);
|
|
59
|
+
if (byEvent) return byEvent;
|
|
60
|
+
const byProcess = processEvidence(row, opts.inspectProcess);
|
|
61
|
+
if (byProcess) return byProcess;
|
|
62
|
+
const byCommand = commandEvidence(row);
|
|
63
|
+
if (!byCommand) return null;
|
|
64
|
+
if (!runtimePaneMatches(row, pane) && !runtimePidMatches(row, pane)) return null;
|
|
65
|
+
return {
|
|
66
|
+
...byCommand,
|
|
67
|
+
provider
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function eventEvidence(row, opts) {
|
|
72
|
+
if (typeof opts.latestProviderSessionForPeer !== 'function') return null;
|
|
73
|
+
const value = opts.latestProviderSessionForPeer(row.peer, row.provider || row.kind || null);
|
|
74
|
+
if (!value) return null;
|
|
75
|
+
return {
|
|
76
|
+
provider: row.provider || row.kind || null,
|
|
77
|
+
value,
|
|
78
|
+
source: 'events.hook'
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function existingProviderSessionConflict(db, row, session, provider = row?.provider) {
|
|
83
|
+
if (!session?.provider_session_id && !session?.provider_session_name) return null;
|
|
84
|
+
if (!provider) return null;
|
|
85
|
+
if (session.provider_session_id) {
|
|
86
|
+
return db.prepare(`
|
|
87
|
+
SELECT *
|
|
88
|
+
FROM peer_bindings
|
|
89
|
+
WHERE provider = ?
|
|
90
|
+
AND peer <> ?
|
|
91
|
+
AND provider_session_id = ?
|
|
92
|
+
LIMIT 1
|
|
93
|
+
`).get(provider, row.peer, session.provider_session_id) || null;
|
|
94
|
+
}
|
|
95
|
+
return db.prepare(`
|
|
96
|
+
SELECT *
|
|
97
|
+
FROM peer_bindings
|
|
98
|
+
WHERE provider = ?
|
|
99
|
+
AND peer <> ?
|
|
100
|
+
AND provider_session_name = ?
|
|
101
|
+
LIMIT 1
|
|
102
|
+
`).get(provider, row.peer, session.provider_session_name) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function updatePeerProviderSession(db, row, candidate, opts) {
|
|
106
|
+
const t = opts.now();
|
|
107
|
+
const provider = candidate.provider || row.provider;
|
|
108
|
+
const session = providerSessionParts(candidate.value);
|
|
109
|
+
const conflict = existingProviderSessionConflict(db, row, session, provider);
|
|
110
|
+
if (conflict) {
|
|
111
|
+
return {
|
|
112
|
+
peer: row.peer,
|
|
113
|
+
provider,
|
|
114
|
+
action: 'skipped',
|
|
115
|
+
reason: 'provider_session_conflict',
|
|
116
|
+
conflict_peer: conflict.peer,
|
|
117
|
+
provider_session: bindingProviderSessionValue(session)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
db.prepare(`
|
|
123
|
+
UPDATE peer_bindings
|
|
124
|
+
SET provider = ?,
|
|
125
|
+
provider_session_id = ?,
|
|
126
|
+
provider_session_name = ?,
|
|
127
|
+
resume_mode = CASE
|
|
128
|
+
WHEN resume_mode IN ('unknown', 'attached', 'auto', 'command') THEN ?
|
|
129
|
+
ELSE resume_mode
|
|
130
|
+
END,
|
|
131
|
+
resume_arg = COALESCE(NULLIF(resume_arg, runtime_target), ?),
|
|
132
|
+
updated_at = ?
|
|
133
|
+
WHERE peer = ?
|
|
134
|
+
`).run(
|
|
135
|
+
provider,
|
|
136
|
+
session.provider_session_id || null,
|
|
137
|
+
session.provider_session_name || null,
|
|
138
|
+
candidate.source === 'binding.session' ? 'session' : 'detected',
|
|
139
|
+
candidate.value,
|
|
140
|
+
t,
|
|
141
|
+
row.peer
|
|
142
|
+
);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const text = `${err?.code || ''} ${err?.message || ''} ${err?.errstr || ''}`;
|
|
145
|
+
if (!/UNIQUE constraint failed/i.test(text)) throw err;
|
|
146
|
+
const conflict = existingProviderSessionConflict(db, row, session, provider);
|
|
147
|
+
return {
|
|
148
|
+
peer: row.peer,
|
|
149
|
+
provider,
|
|
150
|
+
action: 'skipped',
|
|
151
|
+
reason: 'provider_session_conflict',
|
|
152
|
+
conflict_peer: conflict?.peer || null,
|
|
153
|
+
provider_session: bindingProviderSessionValue(session)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
opts.addEvent(db, 'provider.session.backfilled', row.peer, null, {
|
|
157
|
+
provider,
|
|
158
|
+
provider_session: candidate.value,
|
|
159
|
+
source: candidate.source,
|
|
160
|
+
runtime_target: row.runtime_target || null
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
peer: row.peer,
|
|
164
|
+
provider,
|
|
165
|
+
action: 'backfilled',
|
|
166
|
+
source: candidate.source,
|
|
167
|
+
provider_session: candidate.value,
|
|
168
|
+
runtime_target: row.runtime_target || null
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function reconcileRunningPeerBindings(db, projectCtx = {}, options = {}) {
|
|
173
|
+
const opts = {
|
|
174
|
+
now: typeof options.now === 'function' ? options.now : defaultNow,
|
|
175
|
+
addEvent: typeof options.addEvent === 'function' ? options.addEvent : noopEvent,
|
|
176
|
+
inspectProcess: options.inspectProcess || null,
|
|
177
|
+
latestProviderSessionForPeer: options.latestProviderSessionForPeer || null,
|
|
178
|
+
panes: Array.isArray(options.panes) ? options.panes : []
|
|
179
|
+
};
|
|
180
|
+
const paneByTarget = new Map(opts.panes.filter((pane) => pane?.pane).map((pane) => [pane.pane, pane]));
|
|
181
|
+
const paneByPid = new Map();
|
|
182
|
+
for (const pane of opts.panes) {
|
|
183
|
+
if (pane?.pid && !paneByPid.has(Number(pane.pid))) paneByPid.set(Number(pane.pid), pane);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rows = db.prepare(`
|
|
187
|
+
SELECT p.id AS peer, p.kind, p.status, p.worktree, p.pid, p.last_seen_at,
|
|
188
|
+
b.provider, b.provider_session_id, b.provider_session_name,
|
|
189
|
+
b.resume_mode, b.resume_arg, b.command, b.transport,
|
|
190
|
+
b.runtime_session_id, b.runtime_target, b.updated_at
|
|
191
|
+
FROM peer_bindings b
|
|
192
|
+
JOIN peers p ON p.id = b.peer
|
|
193
|
+
WHERE b.transport = 'tmux'
|
|
194
|
+
AND b.runtime_target IS NOT NULL
|
|
195
|
+
AND p.status IN ('running', 'working', 'busy')
|
|
196
|
+
ORDER BY p.last_seen_at DESC, b.updated_at DESC
|
|
197
|
+
`).all();
|
|
198
|
+
|
|
199
|
+
const results = [];
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
if (bindingHasProviderSession(row)) {
|
|
202
|
+
results.push({
|
|
203
|
+
peer: row.peer,
|
|
204
|
+
provider: row.provider,
|
|
205
|
+
action: 'skipped',
|
|
206
|
+
reason: 'provider_session_known',
|
|
207
|
+
provider_session: bindingProviderSessionValue(row)
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const pane = paneByTarget.get(row.runtime_target) || paneByPid.get(Number(row.pid));
|
|
213
|
+
if (!pane) {
|
|
214
|
+
results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'no_live_tmux_pane' });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (!runtimePaneMatches(row, pane)) {
|
|
218
|
+
results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'runtime_target_mismatch' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!runtimePidMatches(row, pane) && !runtimeCwdMatches(projectCtx.root, row, pane)) {
|
|
222
|
+
results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'weak_runtime_evidence' });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const candidate = tmuxEvidence(row, pane, opts);
|
|
227
|
+
if (!candidate?.value) {
|
|
228
|
+
results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'no_strong_provider_evidence' });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
results.push(updatePeerProviderSession(db, row, candidate, opts));
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
checked: rows.length,
|
|
235
|
+
backfilled: results.filter((result) => result.action === 'backfilled').length,
|
|
236
|
+
results
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { sanitizePeerPart, shortHash } from './format.mjs';
|
|
2
|
+
|
|
3
|
+
export function providerSessionPeerId(kind, providerId) {
|
|
4
|
+
return `${kind}-${sanitizePeerPart(String(providerId || '').slice(0, 8), shortHash(providerId))}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function providerSessionParts(value) {
|
|
8
|
+
if (!value) return { provider_session_id: null, provider_session_name: null };
|
|
9
|
+
const text = String(value);
|
|
10
|
+
const uuidLike = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text);
|
|
11
|
+
return uuidLike
|
|
12
|
+
? { provider_session_id: text, provider_session_name: null }
|
|
13
|
+
: { provider_session_id: null, provider_session_name: text };
|
|
14
|
+
}
|