@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,222 @@
|
|
|
1
|
+
import { shellQuoteArg } from '../../format.mjs';
|
|
2
|
+
import {
|
|
3
|
+
lockArgv,
|
|
4
|
+
lockBaseResource,
|
|
5
|
+
lockLabel,
|
|
6
|
+
lockScope,
|
|
7
|
+
locksConflict,
|
|
8
|
+
normalizeLockScope,
|
|
9
|
+
scopedLockResource
|
|
10
|
+
} from './locks.mjs';
|
|
11
|
+
import {
|
|
12
|
+
shouldHideTimelineMessage,
|
|
13
|
+
uniqueList
|
|
14
|
+
} from './timeline.mjs';
|
|
15
|
+
import { summarizeTask } from '../peers/liveness.mjs';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CLI_NAME = 'hcc';
|
|
18
|
+
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
19
|
+
const DEFAULT_LOCK_TTL = 900;
|
|
20
|
+
|
|
21
|
+
function nowSec() {
|
|
22
|
+
return Math.floor(Date.now() / 1000);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function actionCommand(argv, cliName = DEFAULT_CLI_NAME) {
|
|
26
|
+
if (!argv?.length) return '';
|
|
27
|
+
return [cliName, ...argv].map(shellQuoteArg).join(' ');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function makeAction(kind, argv, reason, mutates = true, extra = {}, config = {}) {
|
|
31
|
+
return {
|
|
32
|
+
kind,
|
|
33
|
+
reason,
|
|
34
|
+
mutates,
|
|
35
|
+
argv,
|
|
36
|
+
command: actionCommand(argv, config.cliName || DEFAULT_CLI_NAME),
|
|
37
|
+
...extra
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function looksLikeMultiTask(task) {
|
|
42
|
+
if (!task) return false;
|
|
43
|
+
const text = `${task.title || ''}\n${task.body || ''}`;
|
|
44
|
+
const bullets = text.split('\n').filter((line) => /^\s*(?:[-*]|\d+[.)])\s+\S+/.test(line)).length;
|
|
45
|
+
const separators = (text.match(/[,,;;、]/g) || []).length;
|
|
46
|
+
return bullets >= 2 || separators >= 3 || /多任务|并行|团队|分工|several tasks|multiple tasks|parallel|team/i.test(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function selectCurrentTask(tasks, peerId) {
|
|
50
|
+
if (!peerId) return null;
|
|
51
|
+
const statusRank = { running: 0, claimed: 1, review: 2, blocked: 3 };
|
|
52
|
+
const openTasks = (tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
|
|
53
|
+
const ownedTasks = openTasks
|
|
54
|
+
.filter((task) => task.owner === peerId)
|
|
55
|
+
.sort((a, b) =>
|
|
56
|
+
(statusRank[a.status] ?? 9) - (statusRank[b.status] ?? 9) ||
|
|
57
|
+
Number(a.priority || 0) - Number(b.priority || 0) ||
|
|
58
|
+
Number(a.id || 0) - Number(b.id || 0));
|
|
59
|
+
return ownedTasks.find((task) => ['running', 'claimed', 'review', 'blocked'].includes(task.status)) || ownedTasks[0] || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function deriveAutomation(snapshot, peer = null, opts = {}, config = {}) {
|
|
63
|
+
const cliName = config.cliName || DEFAULT_CLI_NAME;
|
|
64
|
+
const activePeerTtl = Number(config.activePeerTtl ?? snapshot.active_peer_ttl ?? DEFAULT_ACTIVE_PEER_TTL);
|
|
65
|
+
const defaultLockTtl = Number(config.defaultLockTtl ?? DEFAULT_LOCK_TTL);
|
|
66
|
+
const makeAutomationAction = (kind, argv, reason, mutates = true, extra = {}) =>
|
|
67
|
+
makeAction(kind, argv, reason, mutates, extra, { cliName });
|
|
68
|
+
|
|
69
|
+
const peerId = peer || '';
|
|
70
|
+
const t = Number(snapshot.now || nowSec());
|
|
71
|
+
const peerRow = peerId ? snapshot.peers.find((row) => row.id === peerId) : null;
|
|
72
|
+
const openTasks = (snapshot.tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
|
|
73
|
+
const assignedTasks = peerId ? openTasks.filter((task) => !task.owner && task.assignee === peerId) : [];
|
|
74
|
+
const availableTasks = openTasks.filter((task) => !task.owner && !task.assignee);
|
|
75
|
+
const ownedTask = selectCurrentTask(openTasks, peerId);
|
|
76
|
+
const takeoverReadyTasks = peerId
|
|
77
|
+
? openTasks.filter((task) => task.owner && task.owner !== peerId && task.takeover_ready)
|
|
78
|
+
: [];
|
|
79
|
+
const intent = String(opts.intent || 'work').toLowerCase();
|
|
80
|
+
const scope = normalizeLockScope(opts.scope || opts.lock_scope);
|
|
81
|
+
const resources = uniqueList(Array.isArray(opts.resources) ? opts.resources : (opts.resource ? [opts.resource] : []));
|
|
82
|
+
const ownLocks = peerId ? (snapshot.locks || []).filter((lock) => lock.owner === peerId) : [];
|
|
83
|
+
const requestedLocks = resources.map((resource) => {
|
|
84
|
+
const requested = scopedLockResource(resource, scope);
|
|
85
|
+
const lock = (snapshot.locks || []).find((row) => Number(row.expires_at || 0) > t && locksConflict(row, requested));
|
|
86
|
+
const exact = (snapshot.locks || []).find((row) => Number(row.expires_at || 0) > t && lockBaseResource(row) === requested.base_resource && lockScope(row) === requested.scope);
|
|
87
|
+
return { ...requested, lock, exact };
|
|
88
|
+
});
|
|
89
|
+
const conflict = requestedLocks.find((entry) => entry.lock && entry.lock.owner !== peerId);
|
|
90
|
+
const missingLock = requestedLocks.find((entry) => !entry.lock);
|
|
91
|
+
const unread = peerId ? (snapshot.messages || []).filter((message) =>
|
|
92
|
+
message.read_at === null || message.read_at === undefined) : [];
|
|
93
|
+
const warnings = [];
|
|
94
|
+
if (peerId && !peerRow) warnings.push(`peer ${peerId} is not registered in this project`);
|
|
95
|
+
const lockActions = [];
|
|
96
|
+
const messageActions = [];
|
|
97
|
+
const taskActions = [];
|
|
98
|
+
const automation = {
|
|
99
|
+
schema_version: 1,
|
|
100
|
+
peer: peerId ? {
|
|
101
|
+
id: peerId,
|
|
102
|
+
known: Boolean(peerRow),
|
|
103
|
+
active: Boolean(peerRow && Number(peerRow.age_sec || 0) <= activePeerTtl),
|
|
104
|
+
age_sec: peerRow ? Number(peerRow.age_sec || 0) : null
|
|
105
|
+
} : null,
|
|
106
|
+
current_task: summarizeTask(ownedTask),
|
|
107
|
+
phase: 'idle',
|
|
108
|
+
next_action: makeAutomationAction('none', [], 'no immediate coordination action', false),
|
|
109
|
+
actions: [],
|
|
110
|
+
finish_actions: [],
|
|
111
|
+
warnings
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const orderedUnread = unread
|
|
115
|
+
.filter((message) => message.sender !== peerId)
|
|
116
|
+
.filter((message) => !shouldHideTimelineMessage(message))
|
|
117
|
+
.sort((a, b) => (a.kind === 'task' ? 1 : 0) - (b.kind === 'task' ? 1 : 0) || a.id - b.id);
|
|
118
|
+
for (const message of orderedUnread) {
|
|
119
|
+
if (ownedTask && message.kind === 'task') continue;
|
|
120
|
+
const kind = message.kind === 'task' ? 'msg.inbox' : 'msg.reply';
|
|
121
|
+
const argv = kind === 'msg.reply'
|
|
122
|
+
? ['msg', 'reply', '--from', peerId, '--id', String(message.id), '--body', '<answer>']
|
|
123
|
+
: ['msg', 'inbox', '--peer', peerId];
|
|
124
|
+
messageActions.push(makeAutomationAction(kind, argv, `unread message #${message.id} from ${message.sender}`, kind !== 'msg.inbox', {
|
|
125
|
+
message_id: message.id,
|
|
126
|
+
task_id: message.task_id || null
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!ownedTask && assignedTasks.length) {
|
|
131
|
+
const task = assignedTasks[0];
|
|
132
|
+
taskActions.push(makeAutomationAction('task.claim', ['task', 'claim', '--peer', peerId, '--id', String(task.id)], `assigned task #${task.id}`, true, { task_id: task.id }));
|
|
133
|
+
} else if (!ownedTask && takeoverReadyTasks.length) {
|
|
134
|
+
const task = takeoverReadyTasks[0];
|
|
135
|
+
taskActions.push(makeAutomationAction(
|
|
136
|
+
'task.takeover',
|
|
137
|
+
['task', 'takeover', '--peer', peerId, '--id', String(task.id), '--reason', 'owner stale and no active related locks', '--policy', 'stale'],
|
|
138
|
+
`task #${task.id} owner ${task.owner} is stale and has no active related locks`,
|
|
139
|
+
true,
|
|
140
|
+
{ task_id: task.id, owner: task.owner, owner_age_sec: task.owner_age_sec ?? null }
|
|
141
|
+
));
|
|
142
|
+
} else if (!ownedTask && availableTasks.length) {
|
|
143
|
+
taskActions.push(makeAutomationAction('task.next', ['task', 'next', '--peer', peerId], 'available pending task exists', true));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (ownedTask) {
|
|
147
|
+
const readOnlyIntent = ['read', 'review', 'inspect'].includes(intent);
|
|
148
|
+
if (readOnlyIntent && resources.length) {
|
|
149
|
+
warnings.push(`intent=${intent} is read-only; do not acquire file locks for snapshot inspection`);
|
|
150
|
+
} else if (conflict) {
|
|
151
|
+
const requestedLabel = lockLabel(conflict);
|
|
152
|
+
const heldLabel = lockLabel(conflict.lock);
|
|
153
|
+
lockActions.push(makeAutomationAction(
|
|
154
|
+
'msg.send',
|
|
155
|
+
['msg', 'send', '--from', peerId, '--to', conflict.lock.owner, '--task', String(ownedTask.id), '--body', `Please coordinate ${requestedLabel}; ${heldLabel} is locked by ${conflict.lock.owner}. If our edits are separate, split to scoped locks before final tests/commit.`],
|
|
156
|
+
`${requestedLabel} conflicts with ${heldLabel} held by ${conflict.lock.owner}`,
|
|
157
|
+
true,
|
|
158
|
+
{ task_id: ownedTask.id, resource: conflict.base_resource, scope: conflict.scope, lock_owner: conflict.lock.owner, lock_resource: conflict.lock.resource, lock_scope: lockScope(conflict.lock) }
|
|
159
|
+
));
|
|
160
|
+
} else if (missingLock) {
|
|
161
|
+
lockActions.push(makeAutomationAction(
|
|
162
|
+
'lock.acquire',
|
|
163
|
+
['lock', 'acquire', '--peer', peerId, '--task', String(ownedTask.id), ...lockArgv(missingLock.base_resource, missingLock.scope), '--ttl', String(defaultLockTtl), '--reason', '<work>'],
|
|
164
|
+
`task #${ownedTask.id} needs ${lockLabel(missingLock)} lock`,
|
|
165
|
+
true,
|
|
166
|
+
{ task_id: ownedTask.id, resource: missingLock.base_resource, scope: missingLock.scope }
|
|
167
|
+
));
|
|
168
|
+
}
|
|
169
|
+
if (ownedTask.status === 'claimed') {
|
|
170
|
+
taskActions.push(makeAutomationAction('task.update', ['task', 'update', '--peer', peerId, '--id', String(ownedTask.id), '--status', 'running', '--summary', '<started>'], `task #${ownedTask.id} is claimed but not running`, true, { task_id: ownedTask.id }));
|
|
171
|
+
}
|
|
172
|
+
if (!ownedTask.parent_id && looksLikeMultiTask(ownedTask)) {
|
|
173
|
+
const childCount = openTasks.filter((task) => task.parent_id === ownedTask.id && !['done', 'abandoned'].includes(task.status)).length;
|
|
174
|
+
if (!childCount) {
|
|
175
|
+
taskActions.push(makeAutomationAction('team.plan', ['team', 'plan', '--from-task', String(ownedTask.id)], `task #${ownedTask.id} looks splittable; plan explicit team subtasks`, false, { task_id: ownedTask.id }));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
automation.finish_actions.push(makeAutomationAction('handoff.create', ['handoff', 'create', '--from', peerId, '--task', String(ownedTask.id), '--summary', '<summary>', '--tests', '<tests>', '--risks', '<risks>'], `handoff task #${ownedTask.id} before stopping`, true, { task_id: ownedTask.id }));
|
|
179
|
+
automation.finish_actions.push(makeAutomationAction('task.done', ['task', 'done', '--peer', peerId, '--id', String(ownedTask.id), '--summary', '<summary>'], `mark task #${ownedTask.id} done after handoff`, true, { task_id: ownedTask.id }));
|
|
180
|
+
for (const lock of ownLocks) {
|
|
181
|
+
automation.finish_actions.push(makeAutomationAction('lock.release', ['lock', 'release', '--peer', peerId, ...lockArgv(lockBaseResource(lock), lockScope(lock))], `release ${lockLabel(lock)}`, true, { task_id: lock.task_id || null, resource: lockBaseResource(lock), scope: lockScope(lock) }));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
automation.actions.push(...lockActions, ...messageActions, ...taskActions);
|
|
185
|
+
|
|
186
|
+
if (opts.intent === 'finish' || opts.intent === 'stop') {
|
|
187
|
+
automation.phase = ownedTask ? 'handoff' : 'idle';
|
|
188
|
+
automation.next_action = automation.finish_actions[0] || automation.next_action;
|
|
189
|
+
return automation;
|
|
190
|
+
}
|
|
191
|
+
automation.next_action = automation.actions[0] || (ownedTask
|
|
192
|
+
? makeAutomationAction('none', [], `continue task #${ownedTask.id}`, false, { task_id: ownedTask.id })
|
|
193
|
+
: automation.next_action);
|
|
194
|
+
if (automation.next_action.kind === 'msg.reply' || automation.next_action.kind === 'msg.inbox') automation.phase = 'reply_message';
|
|
195
|
+
else if (automation.next_action.kind === 'task.claim' || automation.next_action.kind === 'task.next') automation.phase = 'claim_task';
|
|
196
|
+
else if (automation.next_action.kind === 'task.takeover') automation.phase = 'takeover_task';
|
|
197
|
+
else if (automation.next_action.kind === 'lock.acquire') automation.phase = 'acquire_lock';
|
|
198
|
+
else if (automation.next_action.kind === 'msg.send') automation.phase = 'coordinate_lock';
|
|
199
|
+
else if (automation.next_action.kind === 'team.plan') automation.phase = 'team_plan';
|
|
200
|
+
else if (ownedTask) automation.phase = ownedTask.status === 'review' ? 'handoff' : 'work';
|
|
201
|
+
return automation;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function renderAutomationContext(automation) {
|
|
205
|
+
if (!automation) return '';
|
|
206
|
+
const lines = [
|
|
207
|
+
'[hello-cc next action]',
|
|
208
|
+
`phase: ${automation.phase}`,
|
|
209
|
+
automation.current_task ? `current_task: #${automation.current_task.id} ${automation.current_task.status} ${automation.current_task.title}` : null,
|
|
210
|
+
`next: ${automation.next_action.command || automation.next_action.kind}`,
|
|
211
|
+
`why: ${automation.next_action.reason}`
|
|
212
|
+
].filter(Boolean);
|
|
213
|
+
if (automation.finish_actions?.length) {
|
|
214
|
+
lines.push('finish:');
|
|
215
|
+
for (const action of automation.finish_actions.slice(0, 4)) lines.push(`- ${action.command}`);
|
|
216
|
+
}
|
|
217
|
+
if (automation.warnings?.length) {
|
|
218
|
+
lines.push('warnings:');
|
|
219
|
+
for (const warning of automation.warnings.slice(0, 4)) lines.push(`- ${warning}`);
|
|
220
|
+
}
|
|
221
|
+
return lines.join('\n');
|
|
222
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
|
|
3
|
+
export const WHOLE_LOCK_SCOPE = '*';
|
|
4
|
+
|
|
5
|
+
export function normalizeLockScope(scope) {
|
|
6
|
+
const text = String(scope || '').trim();
|
|
7
|
+
return text || WHOLE_LOCK_SCOPE;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function scopedLockResource(resource, scope = WHOLE_LOCK_SCOPE) {
|
|
11
|
+
const baseResource = String(resource || '').trim();
|
|
12
|
+
const normalizedScope = normalizeLockScope(scope);
|
|
13
|
+
if (!baseResource) throw new CliError('BAD_ARGS', 'Missing --resource');
|
|
14
|
+
return {
|
|
15
|
+
resource: normalizedScope === WHOLE_LOCK_SCOPE
|
|
16
|
+
? baseResource
|
|
17
|
+
: `scoped:${Buffer.from(JSON.stringify([baseResource, normalizedScope]), 'utf8').toString('base64url')}`,
|
|
18
|
+
base_resource: baseResource,
|
|
19
|
+
scope: normalizedScope
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function lockBaseResource(lock) {
|
|
24
|
+
return lock?.base_resource || lock?.resource || '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function lockScope(lock) {
|
|
28
|
+
return normalizeLockScope(lock?.scope);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function lockLabel(lock) {
|
|
32
|
+
const base = lockBaseResource(lock);
|
|
33
|
+
const scope = lockScope(lock);
|
|
34
|
+
return scope === WHOLE_LOCK_SCOPE ? base : `${base} [${scope}]`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function lockArgv(resource, scope) {
|
|
38
|
+
const argv = ['--resource', resource];
|
|
39
|
+
if (normalizeLockScope(scope) !== WHOLE_LOCK_SCOPE) argv.push('--scope', normalizeLockScope(scope));
|
|
40
|
+
return argv;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function locksConflict(a, b) {
|
|
44
|
+
return lockBaseResource(a) === lockBaseResource(b) &&
|
|
45
|
+
(lockScope(a) === WHOLE_LOCK_SCOPE || lockScope(b) === WHOLE_LOCK_SCOPE || lockScope(a) === lockScope(b));
|
|
46
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
|
|
3
|
+
function defaultNow() {
|
|
4
|
+
return Math.floor(Date.now() / 1000);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function noopEvent() {}
|
|
8
|
+
|
|
9
|
+
export function createMessageStore(deps = {}) {
|
|
10
|
+
const now = typeof deps.now === 'function' ? deps.now : defaultNow;
|
|
11
|
+
const addEvent = typeof deps.addEvent === 'function' ? deps.addEvent : noopEvent;
|
|
12
|
+
|
|
13
|
+
function sendMessage(db, sender, recipient, taskId, kind, body, meta = {}) {
|
|
14
|
+
const replyTo = meta.reply_to || null;
|
|
15
|
+
const threadId = meta.thread_id || null;
|
|
16
|
+
const info = db.prepare(`
|
|
17
|
+
INSERT INTO messages(sender, recipient, task_id, kind, body, reply_to, thread_id, created_at)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
19
|
+
`).run(sender, recipient || 'all', taskId || null, kind || 'note', body, replyTo, threadId, now());
|
|
20
|
+
const messageId = Number(info.lastInsertRowid);
|
|
21
|
+
if (!threadId) {
|
|
22
|
+
db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(messageId, messageId);
|
|
23
|
+
}
|
|
24
|
+
addEvent(db, 'message.sent', sender, taskId || null, {
|
|
25
|
+
message_id: messageId,
|
|
26
|
+
recipient: recipient || 'all',
|
|
27
|
+
kind: kind || 'note',
|
|
28
|
+
reply_to: replyTo,
|
|
29
|
+
thread_id: threadId || messageId
|
|
30
|
+
});
|
|
31
|
+
return messageId;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function queryInbox(db, peer, includeAll, limit) {
|
|
35
|
+
return db.prepare(`
|
|
36
|
+
SELECT
|
|
37
|
+
m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
38
|
+
m.reply_to, m.thread_id, m.created_at, r.read_at
|
|
39
|
+
FROM messages m
|
|
40
|
+
LEFT JOIN message_reads r
|
|
41
|
+
ON r.message_id = m.id AND r.peer = ?
|
|
42
|
+
WHERE
|
|
43
|
+
(m.recipient IS NULL OR m.recipient = '' OR m.recipient = 'all' OR m.recipient = ?)
|
|
44
|
+
AND (? = 1 OR r.read_at IS NULL)
|
|
45
|
+
ORDER BY m.id ASC
|
|
46
|
+
LIMIT ?
|
|
47
|
+
`).all(peer, peer, includeAll ? 1 : 0, limit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function queryTimelineMessages(db, peer, limit) {
|
|
51
|
+
if (!peer) {
|
|
52
|
+
return db.prepare(`
|
|
53
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
54
|
+
FROM messages
|
|
55
|
+
ORDER BY id DESC
|
|
56
|
+
LIMIT ?
|
|
57
|
+
`).all(limit).reverse();
|
|
58
|
+
}
|
|
59
|
+
return db.prepare(`
|
|
60
|
+
SELECT
|
|
61
|
+
m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
62
|
+
m.reply_to, m.thread_id, m.created_at, r.read_at
|
|
63
|
+
FROM messages m
|
|
64
|
+
LEFT JOIN message_reads r
|
|
65
|
+
ON r.message_id = m.id AND r.peer = ?
|
|
66
|
+
WHERE
|
|
67
|
+
m.sender = ?
|
|
68
|
+
OR m.recipient IS NULL
|
|
69
|
+
OR m.recipient = ''
|
|
70
|
+
OR m.recipient = 'all'
|
|
71
|
+
OR m.recipient = ?
|
|
72
|
+
ORDER BY m.id DESC
|
|
73
|
+
LIMIT ?
|
|
74
|
+
`).all(peer, peer, peer, limit).reverse();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getMessage(db, id) {
|
|
78
|
+
return db.prepare(`
|
|
79
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
80
|
+
FROM messages
|
|
81
|
+
WHERE id = ?
|
|
82
|
+
`).get(id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function queryMessageThread(db, messageId, limit) {
|
|
86
|
+
const message = getMessage(db, messageId);
|
|
87
|
+
if (!message) throw new CliError('NOT_FOUND', `Message #${messageId} does not exist`);
|
|
88
|
+
const threadId = message.thread_id || message.id;
|
|
89
|
+
const rows = db.prepare(`
|
|
90
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
91
|
+
FROM messages
|
|
92
|
+
WHERE id = ? OR thread_id = ?
|
|
93
|
+
ORDER BY id ASC
|
|
94
|
+
LIMIT ?
|
|
95
|
+
`).all(message.id, threadId, limit);
|
|
96
|
+
return { message, thread_id: threadId, messages: rows };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ackMessage(db, peer, message) {
|
|
100
|
+
db.prepare(`
|
|
101
|
+
INSERT INTO message_reads(message_id, peer, read_at)
|
|
102
|
+
VALUES (?, ?, ?)
|
|
103
|
+
ON CONFLICT(message_id, peer) DO UPDATE SET read_at = excluded.read_at
|
|
104
|
+
`).run(message.id, peer, now());
|
|
105
|
+
addEvent(db, 'message.ack', peer, message.task_id || null, { message_id: message.id });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ackMessage,
|
|
110
|
+
getMessage,
|
|
111
|
+
queryInbox,
|
|
112
|
+
queryMessageThread,
|
|
113
|
+
queryTimelineMessages,
|
|
114
|
+
sendMessage
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|