@logicseek/hello-cc 0.1.5 → 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 +75 -0
- package/bin/hcc.mjs +1541 -3430
- 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/layout-split-stack-audit.md +298 -0
- package/lib/automation.mjs +8 -0
- package/lib/cli-args.mjs +1 -1
- 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 -324
- package/lib/errors.mjs +1 -7
- 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 -14
- 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 -45
- package/lib/runtime-state.mjs +11 -0
- package/lib/session-launch.mjs +17 -0
- package/lib/setup.mjs +17 -293
- package/lib/shared/errors.mjs +7 -0
- package/lib/shared/json-file.mjs +14 -0
- package/lib/shim-script.mjs +1 -384
- 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 -55
- package/lib/web-peer-actions.mjs +3 -0
- package/lib/web-runtime.mjs +17 -108
- package/lib/web-ui-template.mjs +3 -1909
- package/package.json +1 -1
- package/scripts/github-release.mjs +2 -2
- package/scripts/regression.mjs +2468 -17
- package/scripts/release-notes.mjs +2 -2
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { tx } from '../../db/schema.mjs';
|
|
2
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
5
|
+
|
|
6
|
+
function defaultNow() {
|
|
7
|
+
return Math.floor(Date.now() / 1000);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function noopEvent() {}
|
|
11
|
+
|
|
12
|
+
function noopMessage() {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createTaskStore(deps = {}) {
|
|
17
|
+
const now = typeof deps.now === 'function' ? deps.now : defaultNow;
|
|
18
|
+
const addEvent = typeof deps.addEvent === 'function' ? deps.addEvent : noopEvent;
|
|
19
|
+
const sendMessage = typeof deps.sendMessage === 'function' ? deps.sendMessage : noopMessage;
|
|
20
|
+
const activePeerTtl = Number(deps.activePeerTtl || DEFAULT_ACTIVE_PEER_TTL);
|
|
21
|
+
|
|
22
|
+
function claimTaskRowsForPeer(db, peer, ids, { force = false, source = null } = {}) {
|
|
23
|
+
return tx(db, () => {
|
|
24
|
+
const tasks = [];
|
|
25
|
+
for (const id of ids) {
|
|
26
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
27
|
+
if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
|
|
28
|
+
if (row.owner && row.owner !== peer && !force) {
|
|
29
|
+
throw new CliError('TASK_OWNED', `Task #${id} is owned by ${row.owner}`, { owner: row.owner });
|
|
30
|
+
}
|
|
31
|
+
if (row.assignee && row.assignee !== peer && !force) {
|
|
32
|
+
throw new CliError('TASK_ASSIGNED', `Task #${id} is assigned to ${row.assignee}`, { assignee: row.assignee });
|
|
33
|
+
}
|
|
34
|
+
if (!['pending', 'blocked', 'claimed', 'running'].includes(row.status) && !force) {
|
|
35
|
+
throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
|
|
36
|
+
}
|
|
37
|
+
const t = now();
|
|
38
|
+
db.prepare(`
|
|
39
|
+
UPDATE tasks
|
|
40
|
+
SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
|
|
41
|
+
WHERE id = ?
|
|
42
|
+
`).run(peer, t, t, id);
|
|
43
|
+
addEvent(db, 'task.claimed', peer, id, {
|
|
44
|
+
previous_owner: row.owner,
|
|
45
|
+
force,
|
|
46
|
+
...(source ? { source } : {})
|
|
47
|
+
});
|
|
48
|
+
tasks.push(db.prepare('SELECT * FROM tasks WHERE id = ?').get(id));
|
|
49
|
+
}
|
|
50
|
+
return tasks;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function takeoverPolicyDetails(db, row, peer, { policy = 'any', staleAfter = activePeerTtl } = {}) {
|
|
55
|
+
const normalized = String(policy || 'any').toLowerCase();
|
|
56
|
+
const allowed = new Set(['any', 'blocked', 'stale', 'blocked-or-stale']);
|
|
57
|
+
if (!allowed.has(normalized)) {
|
|
58
|
+
throw new CliError('BAD_ARGS', `Unsupported takeover policy: ${policy}`);
|
|
59
|
+
}
|
|
60
|
+
const previousOwner = row.owner || null;
|
|
61
|
+
const blocked = row.status === 'blocked';
|
|
62
|
+
const ownerRow = previousOwner ? db.prepare('SELECT id, last_seen_at FROM peers WHERE id = ?').get(previousOwner) : null;
|
|
63
|
+
const ownerAge = ownerRow ? now() - Number(ownerRow.last_seen_at || 0) : null;
|
|
64
|
+
const ownerStale = Boolean(previousOwner && previousOwner !== peer && (!ownerRow || ownerAge > staleAfter));
|
|
65
|
+
const alreadyOwner = previousOwner === peer;
|
|
66
|
+
const ok = alreadyOwner ||
|
|
67
|
+
normalized === 'any' ||
|
|
68
|
+
(normalized === 'blocked' && blocked) ||
|
|
69
|
+
(normalized === 'stale' && ownerStale) ||
|
|
70
|
+
(normalized === 'blocked-or-stale' && (blocked || ownerStale));
|
|
71
|
+
return { policy: normalized, blocked, owner_stale: ownerStale, owner_age_sec: ownerAge, stale_after_sec: staleAfter, ok };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function takeOverTaskForPeer(db, peer, id, { reason, policy = 'any', staleAfter = activePeerTtl, source = null } = {}) {
|
|
75
|
+
return tx(db, () => {
|
|
76
|
+
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
77
|
+
if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
|
|
78
|
+
if (['done', 'abandoned'].includes(row.status)) {
|
|
79
|
+
throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
|
|
80
|
+
}
|
|
81
|
+
const policyDetails = takeoverPolicyDetails(db, row, peer, { policy, staleAfter });
|
|
82
|
+
if (!policyDetails.ok) {
|
|
83
|
+
throw new CliError('TAKEOVER_POLICY', `Task #${id} does not match takeover policy ${policyDetails.policy}`, {
|
|
84
|
+
task_id: id,
|
|
85
|
+
status: row.status,
|
|
86
|
+
owner: row.owner || null,
|
|
87
|
+
policy: policyDetails.policy,
|
|
88
|
+
owner_stale: policyDetails.owner_stale,
|
|
89
|
+
owner_age_sec: policyDetails.owner_age_sec,
|
|
90
|
+
stale_after_sec: policyDetails.stale_after_sec
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const previousOwner = row.owner || null;
|
|
94
|
+
const previousAssignee = row.assignee || null;
|
|
95
|
+
const t = now();
|
|
96
|
+
db.prepare(`
|
|
97
|
+
UPDATE tasks
|
|
98
|
+
SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
|
|
99
|
+
WHERE id = ?
|
|
100
|
+
`).run(peer, t, t, id);
|
|
101
|
+
addEvent(db, 'task.takeover', peer, id, {
|
|
102
|
+
previous_owner: previousOwner,
|
|
103
|
+
previous_assignee: previousAssignee,
|
|
104
|
+
reason,
|
|
105
|
+
policy: policyDetails.policy,
|
|
106
|
+
owner_stale: policyDetails.owner_stale,
|
|
107
|
+
stale_after_sec: staleAfter,
|
|
108
|
+
...(source ? { source } : {})
|
|
109
|
+
});
|
|
110
|
+
if (previousOwner && previousOwner !== peer) {
|
|
111
|
+
sendMessage(db, peer, previousOwner, id, 'task.takeover', `Task #${id} taken over by ${peer}: ${reason}`);
|
|
112
|
+
}
|
|
113
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function queryOpenTasks(db, limit, peer = null) {
|
|
118
|
+
const sql = `
|
|
119
|
+
SELECT * FROM tasks
|
|
120
|
+
WHERE status NOT IN ('done', 'abandoned')
|
|
121
|
+
${peer ? 'AND (owner = ? OR assignee = ?)' : ''}
|
|
122
|
+
ORDER BY
|
|
123
|
+
CASE status
|
|
124
|
+
WHEN 'claimed' THEN 0
|
|
125
|
+
WHEN 'running' THEN 1
|
|
126
|
+
WHEN 'pending' THEN 2
|
|
127
|
+
WHEN 'blocked' THEN 3
|
|
128
|
+
WHEN 'review' THEN 4
|
|
129
|
+
ELSE 5
|
|
130
|
+
END,
|
|
131
|
+
priority ASC,
|
|
132
|
+
id ASC
|
|
133
|
+
LIMIT ?
|
|
134
|
+
`;
|
|
135
|
+
return peer
|
|
136
|
+
? db.prepare(sql).all(peer, peer, limit)
|
|
137
|
+
: db.prepare(sql).all(limit);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function taskById(db, id) {
|
|
141
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function teamChildren(db, parentId) {
|
|
145
|
+
return db.prepare(`
|
|
146
|
+
SELECT *
|
|
147
|
+
FROM tasks
|
|
148
|
+
WHERE parent_id = ?
|
|
149
|
+
ORDER BY priority ASC, id ASC
|
|
150
|
+
`).all(parentId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function teamSummary(db, parentId) {
|
|
154
|
+
const parent = taskById(db, parentId);
|
|
155
|
+
if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
|
|
156
|
+
const children = teamChildren(db, parentId);
|
|
157
|
+
const handoffs = db.prepare(`
|
|
158
|
+
SELECT *
|
|
159
|
+
FROM handoffs
|
|
160
|
+
WHERE task_id = ? OR task_id IN (SELECT id FROM tasks WHERE parent_id = ?)
|
|
161
|
+
ORDER BY id ASC
|
|
162
|
+
`).all(parentId, parentId);
|
|
163
|
+
const counts = {};
|
|
164
|
+
for (const child of children) counts[child.status] = (counts[child.status] || 0) + 1;
|
|
165
|
+
return { parent, children, handoffs, counts };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function claimNextTasksForPeer(db, peer, { force = false, count = 1 } = {}) {
|
|
169
|
+
return tx(db, () => {
|
|
170
|
+
if (!force) {
|
|
171
|
+
const current = db.prepare(`
|
|
172
|
+
SELECT * FROM tasks
|
|
173
|
+
WHERE owner = ?
|
|
174
|
+
AND status IN ('claimed', 'running', 'review', 'blocked')
|
|
175
|
+
ORDER BY
|
|
176
|
+
CASE status
|
|
177
|
+
WHEN 'running' THEN 0
|
|
178
|
+
WHEN 'claimed' THEN 1
|
|
179
|
+
WHEN 'review' THEN 2
|
|
180
|
+
WHEN 'blocked' THEN 3
|
|
181
|
+
ELSE 4
|
|
182
|
+
END,
|
|
183
|
+
priority ASC,
|
|
184
|
+
id ASC
|
|
185
|
+
LIMIT 1
|
|
186
|
+
`).get(peer);
|
|
187
|
+
if (current) return { current: { ...current, current: true }, tasks: [] };
|
|
188
|
+
}
|
|
189
|
+
const rows = db.prepare(`
|
|
190
|
+
SELECT * FROM tasks
|
|
191
|
+
WHERE status = 'pending'
|
|
192
|
+
AND owner IS NULL
|
|
193
|
+
AND (assignee IS NULL OR assignee = ?)
|
|
194
|
+
ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
|
|
195
|
+
LIMIT ?
|
|
196
|
+
`).all(peer, peer, count);
|
|
197
|
+
if (!rows.length) return { current: null, tasks: [] };
|
|
198
|
+
const t = now();
|
|
199
|
+
const claimed = [];
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
const changes = db.prepare(`
|
|
202
|
+
UPDATE tasks
|
|
203
|
+
SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
|
|
204
|
+
WHERE id = ? AND owner IS NULL AND status = 'pending'
|
|
205
|
+
`).run(peer, t, t, row.id).changes;
|
|
206
|
+
if (!changes) continue;
|
|
207
|
+
addEvent(db, 'task.claimed', peer, row.id, { next: true, count });
|
|
208
|
+
claimed.push(db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id));
|
|
209
|
+
}
|
|
210
|
+
return { current: null, tasks: claimed };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
claimNextTasksForPeer,
|
|
216
|
+
claimTaskRowsForPeer,
|
|
217
|
+
queryOpenTasks,
|
|
218
|
+
takeOverTaskForPeer,
|
|
219
|
+
taskById,
|
|
220
|
+
teamChildren,
|
|
221
|
+
teamSummary,
|
|
222
|
+
takeoverPolicyDetails
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
import { sanitizePeerPart } from '../peers/format.mjs';
|
|
3
|
+
|
|
4
|
+
function intTeamOpt(opts, key, fallback = null) {
|
|
5
|
+
if (opts[key] === undefined || opts[key] === null || opts[key] === '') return fallback;
|
|
6
|
+
const value = Number.parseInt(String(opts[key]), 10);
|
|
7
|
+
if (!Number.isFinite(value)) throw new CliError('BAD_ARGS', `--${key} must be an integer`);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function splitCsvList(value) {
|
|
12
|
+
if (Array.isArray(value)) return value.flatMap((item) => splitCsvList(item));
|
|
13
|
+
return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseTeamItems(opts) {
|
|
17
|
+
const rawItems = [
|
|
18
|
+
...splitCsvList(opts.item || []),
|
|
19
|
+
...splitCsvList(opts.items || [])
|
|
20
|
+
];
|
|
21
|
+
return rawItems.map((raw, index) => {
|
|
22
|
+
const parts = String(raw).split(':').map((part) => part.trim());
|
|
23
|
+
let assignee = null;
|
|
24
|
+
let role = null;
|
|
25
|
+
let title = raw.trim();
|
|
26
|
+
if (parts.length >= 3) {
|
|
27
|
+
assignee = parts.shift() || null;
|
|
28
|
+
role = parts.shift() || null;
|
|
29
|
+
title = parts.join(':').trim();
|
|
30
|
+
} else if (parts.length === 2) {
|
|
31
|
+
role = parts[0] || null;
|
|
32
|
+
title = parts[1] || title;
|
|
33
|
+
}
|
|
34
|
+
if (!title) title = `subtask ${index + 1}`;
|
|
35
|
+
return { title, role: role || `worker-${index + 1}`, assignee };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function inferTeamItems(task, opts) {
|
|
40
|
+
const explicit = parseTeamItems(opts);
|
|
41
|
+
if (explicit.length) return explicit;
|
|
42
|
+
const count = Math.max(1, intTeamOpt(opts, 'count', 3));
|
|
43
|
+
const baseTitle = task?.title || 'team task';
|
|
44
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
45
|
+
title: `${baseTitle} / subtask ${index + 1}`,
|
|
46
|
+
role: `worker-${index + 1}`,
|
|
47
|
+
assignee: null
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function expandTeamWorkers(workers, parentId) {
|
|
52
|
+
const expanded = [];
|
|
53
|
+
for (const token of splitCsvList(workers || [])) {
|
|
54
|
+
const match = token.match(/^([A-Za-z][A-Za-z0-9._-]*):([1-9][0-9]*)$/);
|
|
55
|
+
if (!match) {
|
|
56
|
+
expanded.push(token);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const kind = sanitizePeerPart(match[1], 'peer');
|
|
60
|
+
const count = Number.parseInt(match[2], 10);
|
|
61
|
+
for (let i = 1; i <= count; i += 1) expanded.push(`${kind}-team-${parentId}-${i}`);
|
|
62
|
+
}
|
|
63
|
+
return expanded;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function assignTeamWorkers(items, workers, parentId) {
|
|
67
|
+
const workerList = expandTeamWorkers(workers, parentId);
|
|
68
|
+
if (!workerList.length) return items;
|
|
69
|
+
return items.map((item, index) => ({
|
|
70
|
+
...item,
|
|
71
|
+
assignee: item.assignee || workerList[index % workerList.length]
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|