merge(dev): sync dev into main
This commit is contained in:
commit
de232ab994
234 changed files with 32449 additions and 826 deletions
|
|
@ -6,6 +6,7 @@ Start here:
|
|||
- Repo overview and commands: [README.md](README.md)
|
||||
- Working instructions and project conventions: [CLAUDE.md](CLAUDE.md)
|
||||
- Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
|
||||
- Agent team launch/runtime debugging runbook: [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md)
|
||||
|
||||
For new features:
|
||||
- Default home for medium and large features: `src/features/<feature-name>/`
|
||||
|
|
@ -15,6 +16,7 @@ For new features:
|
|||
## Review guidelines
|
||||
|
||||
- Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority.
|
||||
- For team launch hangs, OpenCode `registered`/`bootstrap unconfirmed`, missing teammate replies, or suspicious task logs, follow [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md) before changing code.
|
||||
- Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints.
|
||||
- Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases.
|
||||
- Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`.
|
||||
|
|
|
|||
|
|
@ -110,6 +110,12 @@ Keep orphaned Task calls (no matching subagent) for visibility.
|
|||
Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a team.
|
||||
Official docs: https://code.claude.com/docs/en/agent-teams
|
||||
|
||||
#### Debugging Team Launches And Teammates
|
||||
- Use [`docs/team-management/debugging-agent-teams.md`](docs/team-management/debugging-agent-teams.md) when a team launch hangs, a teammate remains `registered`, OpenCode shows `bootstrap unconfirmed`, messages are missing, or Task Log Stream looks wrong.
|
||||
- Always correlate UI diagnostics with persisted files under `~/.claude/teams/<teamName>/`, live process state, and runtime-specific evidence before changing code.
|
||||
- For OpenCode secondary lanes, do not confuse primary filesystem readiness with lane bootstrap readiness. A missing OpenCode inbox during primary launch is not automatically a bug.
|
||||
- Do not treat `member_briefing` as runtime evidence. OpenCode deliverability requires lane-scoped committed runtime evidence such as `opencode-sessions.json` plus its manifest entry.
|
||||
|
||||
#### Message Delivery Architecture
|
||||
- **Lead** reads ONLY stdin (stream-json). Messages to lead must go through `relayLeadInboxMessages()` which converts inbox entries to stdin.
|
||||
- **Teammates** are independent CLI processes. Claude Code runtime monitors each teammate's inbox file and delivers messages between turns. No relay through lead needed.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const processes = require('./internal/processes.js');
|
|||
const maintenance = require('./internal/maintenance.js');
|
||||
const crossTeam = require('./internal/crossTeam.js');
|
||||
const runtime = require('./internal/runtime.js');
|
||||
const workSync = require('./internal/workSync.js');
|
||||
const agentBlocks = require('./internal/agentBlocks.js');
|
||||
|
||||
function bindModule(context, moduleApi) {
|
||||
|
|
@ -31,6 +32,7 @@ function createController(options) {
|
|||
maintenance: bindModule(context, maintenance),
|
||||
crossTeam: bindModule(context, crossTeam),
|
||||
runtime: bindModule(context, runtime),
|
||||
workSync: bindModule(context, workSync),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -51,4 +53,5 @@ module.exports = {
|
|||
maintenance,
|
||||
crossTeam,
|
||||
runtime,
|
||||
workSync,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -188,6 +188,63 @@ function appendRow(filePath, row) {
|
|||
return row;
|
||||
}
|
||||
|
||||
const RUNTIME_DELIVERY_DUPLICATE_NOTICE =
|
||||
'Duplicate runtime_delivery ignored. The visible reply is already recorded for this relayOfMessageId; do not call agent-teams_message_send again with the same text unless you have new information.';
|
||||
|
||||
function normalizeComparableText(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/[ \t]+/g, ' ');
|
||||
}
|
||||
|
||||
function normalizeComparableParticipant(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getRuntimeDeliveryDuplicate(list, row) {
|
||||
if (
|
||||
row.source !== 'runtime_delivery' ||
|
||||
typeof row.relayOfMessageId !== 'string' ||
|
||||
row.relayOfMessageId.trim().length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relayOfMessageId = row.relayOfMessageId.trim();
|
||||
const from = normalizeComparableParticipant(row.from);
|
||||
const to = normalizeComparableParticipant(row.to);
|
||||
const text = normalizeComparableText(row.text);
|
||||
if (!from || !to || !text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
list.find(
|
||||
(candidate) =>
|
||||
candidate &&
|
||||
candidate.source === 'runtime_delivery' &&
|
||||
String(candidate.relayOfMessageId || '').trim() === relayOfMessageId &&
|
||||
normalizeComparableParticipant(candidate.from) === from &&
|
||||
normalizeComparableParticipant(candidate.to) === to &&
|
||||
normalizeComparableText(candidate.text) === text
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function appendInboxRow(filePath, row) {
|
||||
const current = readJson(filePath, []);
|
||||
const list = Array.isArray(current) ? current : [];
|
||||
const duplicate = getRuntimeDeliveryDuplicate(list, row);
|
||||
if (duplicate) {
|
||||
return { row: duplicate, deduplicated: true };
|
||||
}
|
||||
|
||||
list.push(row);
|
||||
writeJson(filePath, list);
|
||||
return { row, deduplicated: false };
|
||||
}
|
||||
|
||||
function sendInboxMessage(paths, flags) {
|
||||
const memberName =
|
||||
typeof flags.member === 'string' && flags.member.trim()
|
||||
|
|
@ -204,11 +261,18 @@ function sendInboxMessage(paths, flags) {
|
|||
to: memberName,
|
||||
read: false,
|
||||
});
|
||||
appendRow(getInboxPath(paths, memberName), payload);
|
||||
const appended = appendInboxRow(getInboxPath(paths, memberName), payload);
|
||||
return {
|
||||
deliveredToInbox: true,
|
||||
messageId: payload.messageId,
|
||||
message: payload,
|
||||
messageId: appended.row.messageId,
|
||||
message: appended.row,
|
||||
...(appended.deduplicated
|
||||
? {
|
||||
deduplicated: true,
|
||||
duplicateOfMessageId: appended.row.messageId,
|
||||
deduplicationNotice: RUNTIME_DELIVERY_DUPLICATE_NOTICE,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
|||
const POLL_INTERVAL_MS = 1000;
|
||||
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
|
||||
const RETRYABLE_CONTROL_ERROR = 'retryableControlError';
|
||||
const BOOTSTRAP_CHECKIN_MAX_ATTEMPTS = 3;
|
||||
const BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS = 4000;
|
||||
const BOOTSTRAP_CHECKIN_RETRY_DELAYS_MS = [300, 900];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
@ -67,9 +70,15 @@ function resolveControlBaseUrls(context, flags = {}) {
|
|||
return candidates;
|
||||
}
|
||||
|
||||
function makeRetryableControlError(message, cause) {
|
||||
function makeRetryableControlError(message, cause, metadata = {}) {
|
||||
const error = new Error(message);
|
||||
error[RETRYABLE_CONTROL_ERROR] = true;
|
||||
if (metadata.kind) {
|
||||
error.retryableKind = metadata.kind;
|
||||
}
|
||||
if (metadata.statusCode) {
|
||||
error.statusCode = metadata.statusCode;
|
||||
}
|
||||
if (cause) {
|
||||
error.cause = cause;
|
||||
}
|
||||
|
|
@ -114,7 +123,9 @@ async function requestJson(baseUrl, pathname, options = {}) {
|
|||
: `${response.status} ${response.statusText}`.trim();
|
||||
if (isRetryableStatusCode(response.status)) {
|
||||
throw makeRetryableControlError(
|
||||
`Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}`
|
||||
`Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}`,
|
||||
undefined,
|
||||
{ kind: 'status', statusCode: response.status }
|
||||
);
|
||||
}
|
||||
throw new Error(detail || 'Team control API request failed');
|
||||
|
|
@ -122,19 +133,24 @@ async function requestJson(baseUrl, pathname, options = {}) {
|
|||
|
||||
if (payload == null) {
|
||||
throw makeRetryableControlError(
|
||||
`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`
|
||||
`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`,
|
||||
undefined,
|
||||
{ kind: 'empty' }
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') {
|
||||
throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error);
|
||||
throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error, {
|
||||
kind: 'timeout',
|
||||
});
|
||||
}
|
||||
if (error && error.name === 'TypeError') {
|
||||
throw makeRetryableControlError(
|
||||
`Failed to reach team control API at ${baseUrl}: ${error.message || 'fetch failed'}`,
|
||||
error
|
||||
error,
|
||||
{ kind: 'network' }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
|
|
@ -161,6 +177,54 @@ async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
|
|||
throw lastError || new Error('Team control API request failed');
|
||||
}
|
||||
|
||||
function isBootstrapCheckinRetryableControlError(error) {
|
||||
if (!isRetryableControlError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error.retryableKind === 'timeout' || error.retryableKind === 'network') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error.retryableKind === 'status') {
|
||||
return typeof error.statusCode === 'number' && error.statusCode >= 500;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function requestJsonWithBoundedRetry(baseUrls, pathname, options = {}, retryOptions = {}) {
|
||||
const maxAttempts = Math.max(1, retryOptions.maxAttempts || 1);
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const result = await requestJsonWithFallback(baseUrls, pathname, options);
|
||||
if (attempt > 1 && result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
return {
|
||||
...result,
|
||||
diagnostics: uniqueNonEmpty([
|
||||
...(Array.isArray(result.diagnostics) ? result.diagnostics : []),
|
||||
'opencode_bootstrap_checkin_retry',
|
||||
]),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= maxAttempts || !isBootstrapCheckinRetryableControlError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const delayMs = retryOptions.delaysMs?.[attempt - 1] || 0;
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Team control API request failed');
|
||||
}
|
||||
|
||||
function buildLaunchRequest(flags = {}) {
|
||||
const cwd = typeof flags.cwd === 'string' ? flags.cwd.trim() : '';
|
||||
if (!cwd) {
|
||||
|
|
@ -412,18 +476,31 @@ async function getRuntimeState(context, flags = {}) {
|
|||
}
|
||||
|
||||
async function runtimeBootstrapCheckin(context, flags = {}) {
|
||||
return postRuntimeTool(
|
||||
context,
|
||||
flags,
|
||||
'bootstrap-checkin',
|
||||
compactRuntimeToolBody(context, flags, [
|
||||
'runId',
|
||||
'memberName',
|
||||
'runtimeSessionId',
|
||||
'observedAt',
|
||||
'diagnostics',
|
||||
'metadata',
|
||||
])
|
||||
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||
const explicitTimeoutMs = flags.waitTimeoutMs || flags['wait-timeout-ms'];
|
||||
const timeoutMs = Math.min(
|
||||
normalizeTimeoutMs(explicitTimeoutMs || BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS),
|
||||
BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS
|
||||
);
|
||||
return requestJsonWithBoundedRetry(
|
||||
baseUrls,
|
||||
`/api/teams/${encodeURIComponent(context.teamName)}/opencode/runtime/bootstrap-checkin`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: compactRuntimeToolBody(context, flags, [
|
||||
'runId',
|
||||
'memberName',
|
||||
'runtimeSessionId',
|
||||
'observedAt',
|
||||
'diagnostics',
|
||||
'metadata',
|
||||
]),
|
||||
timeoutMs,
|
||||
},
|
||||
{
|
||||
maxAttempts: BOOTSTRAP_CHECKIN_MAX_ATTEMPTS,
|
||||
delaysMs: BOOTSTRAP_CHECKIN_RETRY_DELAYS_MS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -781,6 +781,14 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
|
|||
- If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
|
||||
- Then run task_start only when you truly begin.
|
||||
- If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass.
|
||||
15. MEMBER WORK SYNC REPORTING:
|
||||
- member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda. They do NOT start, complete, approve, or comment on tasks.
|
||||
- Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment.
|
||||
- When you are about to stop, wait, or go idle because you believe your current work queue is handled, first call member_work_sync_status for yourself.
|
||||
- If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working", that exact agendaFingerprint, and the returned reportToken.
|
||||
- If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task, and include the returned reportToken.
|
||||
- If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint and the returned reportToken.
|
||||
- Do not report more than once for the same agendaFingerprint unless your state changed.
|
||||
Failure to follow this protocol means the task board will show incorrect status.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
273
agent-teams-controller/src/internal/workSync.js
Normal file
273
agent-teams-controller/src/internal/workSync.js
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const { withFileLockSync } = require('./fileLock.js');
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 10000;
|
||||
const MIN_WAIT_TIMEOUT_MS = 1000;
|
||||
const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
|
||||
|
||||
function normalizeTimeoutMs(rawValue) {
|
||||
const numeric =
|
||||
typeof rawValue === 'number' && Number.isFinite(rawValue)
|
||||
? Math.floor(rawValue)
|
||||
: DEFAULT_WAIT_TIMEOUT_MS;
|
||||
return Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, numeric));
|
||||
}
|
||||
|
||||
function readControlApiState(context) {
|
||||
const filePath = path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed?.baseUrl === 'string' && parsed.baseUrl.trim()
|
||||
? parsed.baseUrl.trim()
|
||||
: '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveControlBaseUrls(context, flags = {}) {
|
||||
const explicit =
|
||||
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
|
||||
(typeof flags['control-url'] === 'string' && flags['control-url'].trim()) ||
|
||||
'';
|
||||
const stateFileUrl = readControlApiState(context);
|
||||
const envUrl =
|
||||
typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string'
|
||||
? process.env.CLAUDE_TEAM_CONTROL_URL.trim()
|
||||
: '';
|
||||
const candidates = [...new Set([explicit, stateFileUrl, envUrl].filter(Boolean))];
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
'Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports.'
|
||||
);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function requestJson(baseUrl, pathname, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), normalizeTimeoutMs(options.timeoutMs));
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(options.body ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
payload && typeof payload.error === 'string' && payload.error.trim()
|
||||
? payload.error.trim()
|
||||
: `${response.status} ${response.statusText}`.trim();
|
||||
const error = new Error(detail || 'Team control API request failed');
|
||||
error.controlApiStatus = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
|
||||
let lastError = null;
|
||||
for (const baseUrl of baseUrls) {
|
||||
try {
|
||||
return await requestJson(baseUrl, pathname, options);
|
||||
} catch (error) {
|
||||
if (error && error.controlApiStatus) {
|
||||
throw error;
|
||||
}
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('Team control API request failed');
|
||||
}
|
||||
|
||||
function compactReportBody(context, memberName, flags = {}) {
|
||||
return {
|
||||
teamName: context.teamName,
|
||||
memberName,
|
||||
state: flags.state,
|
||||
agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'],
|
||||
reportToken: flags.reportToken || flags['report-token'],
|
||||
...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}),
|
||||
...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}),
|
||||
...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}),
|
||||
...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim()
|
||||
? { reportedAt: flags.reportedAt.trim() }
|
||||
: {}),
|
||||
...(typeof flags.leaseTtlMs === 'number' ? { leaseTtlMs: flags.leaseTtlMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(',')}]`;
|
||||
}
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
function buildPendingIntentId(body) {
|
||||
const taskIds = Array.isArray(body.taskIds)
|
||||
? Array.from(new Set(body.taskIds.map((taskId) => String(taskId)).filter(Boolean))).sort()
|
||||
: [];
|
||||
const payload = {
|
||||
teamName: body.teamName,
|
||||
memberName: String(body.memberName || '').trim().toLowerCase(),
|
||||
state: body.state,
|
||||
agendaFingerprint: body.agendaFingerprint,
|
||||
reportToken: body.reportToken || '',
|
||||
...(taskIds.length > 0 ? { taskIds } : {}),
|
||||
...(body.note ? { note: body.note } : {}),
|
||||
...(body.leaseTtlMs ? { leaseTtlMs: body.leaseTtlMs } : {}),
|
||||
...(body.source ? { source: body.source } : {}),
|
||||
};
|
||||
return `member-work-sync-intent:${crypto
|
||||
.createHash('sha256')
|
||||
.update(stableStringify(payload))
|
||||
.digest('hex')}`;
|
||||
}
|
||||
|
||||
function readPendingReportFile(filePath) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
parsed.schemaVersion === 1 &&
|
||||
parsed.intents &&
|
||||
typeof parsed.intents === 'object' &&
|
||||
!Array.isArray(parsed.intents)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return { schemaVersion: 1, intents: {} };
|
||||
}
|
||||
|
||||
function writePendingReportFile(filePath, data) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
function appendPendingReportIntent(context, body, reason) {
|
||||
const filePath = path.join(context.paths.teamDir, '.member-work-sync', 'pending-reports.json');
|
||||
const { id } = withFileLockSync(filePath, () => {
|
||||
const data = readPendingReportFile(filePath);
|
||||
const request = {
|
||||
...body,
|
||||
source: 'mcp',
|
||||
};
|
||||
const intentId = buildPendingIntentId(request);
|
||||
const current = data.intents[intentId];
|
||||
if (!current || current.status === 'pending') {
|
||||
data.intents[intentId] = {
|
||||
id: intentId,
|
||||
teamName: body.teamName,
|
||||
memberName: body.memberName,
|
||||
request,
|
||||
reason,
|
||||
status: 'pending',
|
||||
recordedAt: current && current.recordedAt ? current.recordedAt : new Date().toISOString(),
|
||||
};
|
||||
writePendingReportFile(filePath, data);
|
||||
}
|
||||
return { id: intentId };
|
||||
});
|
||||
return {
|
||||
accepted: false,
|
||||
pendingValidation: true,
|
||||
code: 'pending_validation',
|
||||
message:
|
||||
'Member work sync report was recorded for app validation. Continue concrete task work; do not treat this as a confirmed lease yet.',
|
||||
intentId: id,
|
||||
};
|
||||
}
|
||||
|
||||
function assertReportBody(body) {
|
||||
if (!body.state || !['still_working', 'blocked', 'caught_up'].includes(body.state)) {
|
||||
throw new Error('state must be still_working, blocked, or caught_up');
|
||||
}
|
||||
if (!body.agendaFingerprint) {
|
||||
throw new Error('agendaFingerprint is required');
|
||||
}
|
||||
if (!body.reportToken) {
|
||||
throw new Error('reportToken is required');
|
||||
}
|
||||
}
|
||||
|
||||
async function memberWorkSyncStatus(context, flags = {}) {
|
||||
const memberName = runtimeHelpers.assertExplicitTeamMemberName(
|
||||
context.paths,
|
||||
flags.memberName || flags.member || flags.from,
|
||||
'member work sync status member'
|
||||
);
|
||||
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||
return requestJsonWithFallback(
|
||||
baseUrls,
|
||||
`/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent(
|
||||
memberName
|
||||
)}`,
|
||||
{ timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) }
|
||||
);
|
||||
}
|
||||
|
||||
async function memberWorkSyncReport(context, flags = {}) {
|
||||
const memberName = runtimeHelpers.assertExplicitTeamMemberName(
|
||||
context.paths,
|
||||
flags.memberName || flags.member || flags.from,
|
||||
'member work sync report member'
|
||||
);
|
||||
const body = compactReportBody(context, memberName, flags);
|
||||
assertReportBody(body);
|
||||
|
||||
const pathname = `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`;
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body,
|
||||
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']),
|
||||
};
|
||||
|
||||
let baseUrls;
|
||||
try {
|
||||
baseUrls = resolveControlBaseUrls(context, flags);
|
||||
} catch {
|
||||
return appendPendingReportIntent(context, body, 'control_api_unavailable');
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJsonWithFallback(baseUrls, pathname, options);
|
||||
} catch (error) {
|
||||
if (error && error.controlApiStatus) {
|
||||
throw error;
|
||||
}
|
||||
return appendPendingReportIntent(context, body, 'control_api_unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
memberWorkSyncStatus,
|
||||
memberWorkSyncReport,
|
||||
};
|
||||
|
|
@ -63,6 +63,8 @@ const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [
|
|||
'runtime_heartbeat',
|
||||
];
|
||||
|
||||
const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES = ['member_work_sync_status', 'member_work_sync_report'];
|
||||
|
||||
const AGENT_TEAMS_MCP_TOOL_GROUPS = [
|
||||
{
|
||||
id: 'team',
|
||||
|
|
@ -104,6 +106,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [
|
|||
teammateOperational: false,
|
||||
toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES,
|
||||
},
|
||||
{
|
||||
id: 'workSync',
|
||||
teammateOperational: true,
|
||||
toolNames: AGENT_TEAMS_WORK_SYNC_TOOL_NAMES,
|
||||
},
|
||||
{
|
||||
id: 'crossTeam',
|
||||
teammateOperational: true,
|
||||
|
|
@ -141,6 +148,7 @@ module.exports = {
|
|||
AGENT_TEAMS_PROCESS_TOOL_NAMES,
|
||||
AGENT_TEAMS_KANBAN_TOOL_NAMES,
|
||||
AGENT_TEAMS_RUNTIME_TOOL_NAMES,
|
||||
AGENT_TEAMS_WORK_SYNC_TOOL_NAMES,
|
||||
AGENT_TEAMS_MCP_TOOL_GROUPS,
|
||||
AGENT_TEAMS_REGISTERED_TOOL_NAMES,
|
||||
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain('Task briefing for bob:');
|
||||
expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.');
|
||||
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
|
||||
expect(briefing).toContain('member_work_sync_status and member_work_sync_report');
|
||||
expect(briefing).toContain(
|
||||
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||
);
|
||||
|
|
@ -234,6 +235,44 @@ describe('agent-teams-controller API', () => {
|
|||
expect(delivered.deliveredToInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('deduplicates repeated runtime_delivery replies to the same inbound message', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members = [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' },
|
||||
];
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const first = controller.messages.sendMessage({
|
||||
to: 'user',
|
||||
from: 'bob',
|
||||
text: 'Да, я здесь!',
|
||||
source: 'runtime_delivery',
|
||||
relayOfMessageId: 'msg-inbound-1',
|
||||
});
|
||||
const second = controller.messages.sendMessage({
|
||||
to: 'user',
|
||||
from: 'bob',
|
||||
text: ' Да, я здесь! ',
|
||||
source: 'runtime_delivery',
|
||||
relayOfMessageId: 'msg-inbound-1',
|
||||
});
|
||||
|
||||
const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
|
||||
const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(second).toMatchObject({
|
||||
deliveredToInbox: true,
|
||||
deduplicated: true,
|
||||
messageId: first.messageId,
|
||||
duplicateOfMessageId: first.messageId,
|
||||
deduplicationNotice: expect.stringContaining('do not call agent-teams_message_send again'),
|
||||
});
|
||||
});
|
||||
|
||||
it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -2250,6 +2289,301 @@ describe('agent-teams-controller API', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('retries OpenCode bootstrap check-in on retryable control API failures', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const calls = [];
|
||||
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
if (calls.length < 3) {
|
||||
return { statusCode: 500, body: { error: 'temporary bootstrap failure' } };
|
||||
}
|
||||
return { body: { ok: true, state: 'accepted', diagnostics: [] } };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await controller.runtime.runtimeBootstrapCheckin({
|
||||
controlUrl: server.baseUrl,
|
||||
runId: 'run-oc',
|
||||
memberName: 'bob',
|
||||
runtimeSessionId: 'ses-1',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
state: 'accepted',
|
||||
diagnostics: expect.arrayContaining(['opencode_bootstrap_checkin_retry']),
|
||||
});
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls.map((call) => call.body)).toEqual([
|
||||
{
|
||||
teamName: 'my-team',
|
||||
runId: 'run-oc',
|
||||
memberName: 'bob',
|
||||
runtimeSessionId: 'ses-1',
|
||||
},
|
||||
{
|
||||
teamName: 'my-team',
|
||||
runId: 'run-oc',
|
||||
memberName: 'bob',
|
||||
runtimeSessionId: 'ses-1',
|
||||
},
|
||||
{
|
||||
teamName: 'my-team',
|
||||
runId: 'run-oc',
|
||||
memberName: 'bob',
|
||||
runtimeSessionId: 'ses-1',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts idempotent OpenCode bootstrap check-in after a timed-out committed request', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const calls = [];
|
||||
let committed = false;
|
||||
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
if (!committed) {
|
||||
committed = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
return { body: { ok: true, state: 'accepted', diagnostics: [] } };
|
||||
}
|
||||
return {
|
||||
body: {
|
||||
ok: true,
|
||||
state: 'accepted',
|
||||
diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await controller.runtime.runtimeBootstrapCheckin({
|
||||
controlUrl: server.baseUrl,
|
||||
waitTimeoutMs: 1000,
|
||||
runId: 'run-oc',
|
||||
memberName: 'bob',
|
||||
runtimeSessionId: 'ses-1',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
state: 'accepted',
|
||||
diagnostics: expect.arrayContaining([
|
||||
'opencode_bootstrap_checkin_duplicate_accepted',
|
||||
'opencode_bootstrap_checkin_retry',
|
||||
]),
|
||||
});
|
||||
expect(calls).toHaveLength(2);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not retry OpenCode bootstrap check-in on validation failures', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const calls = [];
|
||||
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
return { statusCode: 400, body: { error: 'invalid bootstrap payload' } };
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
controller.runtime.runtimeBootstrapCheckin({
|
||||
controlUrl: server.baseUrl,
|
||||
runId: 'run-oc',
|
||||
memberName: 'bob',
|
||||
runtimeSessionId: 'ses-1',
|
||||
})
|
||||
).rejects.toThrow('invalid bootstrap payload');
|
||||
expect(calls).toHaveLength(1);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails OpenCode bootstrap check-in clearly after bounded timeout retries', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const calls = [];
|
||||
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
return { body: { ok: true, state: 'accepted' } };
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
controller.runtime.runtimeBootstrapCheckin({
|
||||
controlUrl: server.baseUrl,
|
||||
waitTimeoutMs: 1000,
|
||||
runId: 'run-oc',
|
||||
memberName: 'bob',
|
||||
runtimeSessionId: 'ses-1',
|
||||
})
|
||||
).rejects.toThrow('Timed out calling team control API');
|
||||
expect(calls).toHaveLength(3);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards member work sync status and reports to the app validator', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const calls = [];
|
||||
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
if (method === 'GET' && url === '/api/teams/my-team/member-work-sync/bob') {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'my-team',
|
||||
memberName: 'bob',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'my-team',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:abc',
|
||||
items: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: ['no_current_report'],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/report') {
|
||||
return { body: { accepted: true, code: 'accepted', status: body } };
|
||||
}
|
||||
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||
});
|
||||
|
||||
try {
|
||||
const status = await controller.workSync.memberWorkSyncStatus({
|
||||
controlUrl: server.baseUrl,
|
||||
from: 'bob',
|
||||
});
|
||||
const report = await controller.workSync.memberWorkSyncReport({
|
||||
controlUrl: server.baseUrl,
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
taskIds: ['task-1'],
|
||||
note: 'Continuing work',
|
||||
leaseTtlMs: 120000,
|
||||
});
|
||||
|
||||
expect(status.state).toBe('needs_sync');
|
||||
expect(report.accepted).toBe(true);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/api/teams/my-team/member-work-sync/bob',
|
||||
body: undefined,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/teams/my-team/member-work-sync/report',
|
||||
body: {
|
||||
teamName: 'my-team',
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
taskIds: ['task-1'],
|
||||
note: 'Continuing work',
|
||||
leaseTtlMs: 120000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('records member work sync report intents only when the app validator is unavailable', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pending = await controller.workSync.memberWorkSyncReport({
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
taskIds: ['task-1'],
|
||||
});
|
||||
|
||||
expect(pending.pendingValidation).toBe(true);
|
||||
expect(pending.accepted).toBe(false);
|
||||
|
||||
const intentFile = path.join(
|
||||
claudeDir,
|
||||
'teams',
|
||||
'my-team',
|
||||
'.member-work-sync',
|
||||
'pending-reports.json'
|
||||
);
|
||||
const intents = JSON.parse(fs.readFileSync(intentFile, 'utf8'));
|
||||
expect(Object.values(intents.intents)).toEqual([
|
||||
expect.objectContaining({
|
||||
teamName: 'my-team',
|
||||
memberName: 'bob',
|
||||
reason: 'control_api_unavailable',
|
||||
status: 'pending',
|
||||
request: expect.objectContaining({
|
||||
memberName: 'bob',
|
||||
source: 'mcp',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not record pending work sync intents for app-side validation rejections', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const server = await startControlServer(async () => ({
|
||||
statusCode: 400,
|
||||
body: { error: 'stale_fingerprint' },
|
||||
}));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
controller.workSync.memberWorkSyncReport({
|
||||
controlUrl: server.baseUrl,
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:stale',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
})
|
||||
).rejects.toThrow('stale_fingerprint');
|
||||
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(claudeDir, 'teams', 'my-team', '.member-work-sync', 'pending-reports.json')
|
||||
)
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers the published control endpoint over a stale env URL', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
176
docs/team-management/debugging-agent-teams.md
Normal file
176
docs/team-management/debugging-agent-teams.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Debugging Agent Teams
|
||||
|
||||
Use this runbook when a team launch hangs, a teammate is marked `registered` or `failed_to_start`, messages do not appear, or OpenCode participants look online but do not answer.
|
||||
|
||||
## First Rule
|
||||
|
||||
Do not guess from the UI alone. Always correlate:
|
||||
- UI diagnostics copied from the launch/member detail panel
|
||||
- persisted team files under `~/.claude/teams/<teamName>/`
|
||||
- live process table
|
||||
- runtime-specific evidence, especially OpenCode lane manifests
|
||||
|
||||
## Key Files
|
||||
|
||||
Team root:
|
||||
|
||||
```bash
|
||||
TEAM="<team-name>"
|
||||
TEAM_DIR="$HOME/.claude/teams/$TEAM"
|
||||
```
|
||||
|
||||
Important files and folders:
|
||||
- `config.json` - configured members, provider/model selection, project path
|
||||
- `members-meta.json` - member metadata, removed members, worktree settings if present
|
||||
- `launch-state.json` - current app-side truth for member launch/liveness
|
||||
- `bootstrap-state.json` - bootstrap phase summary when present
|
||||
- `bootstrap-journal.jsonl` - ordered bootstrap events from the CLI/runtime
|
||||
- `inboxes/*.json` - durable inbox messages for user, lead, and native teammates
|
||||
- `sentMessages.json` - app-side sent-message records
|
||||
- `tasks/*.json` - task board state
|
||||
- `.opencode-runtime/lanes.json` - OpenCode lane index
|
||||
- `.opencode-runtime/lanes/<encoded-lane-id>/manifest.json` - lane-scoped runtime store manifest
|
||||
- `.opencode-runtime/lanes/<encoded-lane-id>/opencode-sessions.json` - committed OpenCode session evidence
|
||||
|
||||
Quick inspection:
|
||||
|
||||
```bash
|
||||
jq '.teamLaunchState, .summary, .members' "$TEAM_DIR/launch-state.json"
|
||||
jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null
|
||||
find "$TEAM_DIR/.opencode-runtime" -maxdepth 3 -type f | sort
|
||||
tail -80 "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
|
||||
```
|
||||
|
||||
## Launch Phases
|
||||
|
||||
Primary launch and OpenCode secondary lanes are different paths.
|
||||
|
||||
- Primary CLI members are created by the main provisioning process.
|
||||
- OpenCode secondary members are launched as side lanes after primary filesystem readiness.
|
||||
- Missing `inboxes/<opencode-member>.json` is not automatically a launch bug. OpenCode side lanes do not have to be primary inbox-created before they start.
|
||||
- The UI can show the team still launching while primary members are already usable, because "all teammates joined" waits for secondary lanes too.
|
||||
|
||||
When a launch hangs at `Prepared communication channels for X/Y members`, check whether `Y` incorrectly includes secondary OpenCode members. The filesystem monitor should wait for `effectiveMembers`, not every requested member.
|
||||
|
||||
## Member State Meanings
|
||||
|
||||
Common `launch-state.json` cases:
|
||||
|
||||
- `confirmed_alive` with `bootstrapConfirmed: true` - member is usable.
|
||||
- `registered` / `runtime_pending_bootstrap` - process or lane exists, but bootstrap proof is not committed yet.
|
||||
- `registered_only` - app has persisted metadata, but no live runtime proof.
|
||||
- `runtime_process_candidate` - process/session was observed, but committed runtime evidence is incomplete or pending.
|
||||
- `failed_to_start` with `runtime_process` - a process exists, but the launch gate still failed. Inspect diagnostics and runtime evidence.
|
||||
- `failed_to_start` with `stale_metadata` - persisted pid/session is old or dead.
|
||||
|
||||
Do not treat `member_briefing` alone as runtime evidence. For OpenCode, the authoritative proof is committed bootstrap/session evidence in the lane runtime store.
|
||||
|
||||
## OpenCode Debug Flow
|
||||
|
||||
For an OpenCode teammate:
|
||||
|
||||
```bash
|
||||
MEMBER="<member-name>"
|
||||
jq --arg member "$MEMBER" '.members[$member]' "$TEAM_DIR/launch-state.json"
|
||||
jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null
|
||||
find "$TEAM_DIR/.opencode-runtime/lanes" -maxdepth 3 -type f | sort
|
||||
```
|
||||
|
||||
Expected healthy OpenCode lane:
|
||||
- `lanes.json` has the lane state `active`
|
||||
- lane `manifest.json` has `activeRunId`
|
||||
- lane manifest has at least one runtime evidence entry, usually `opencode.sessionStore`
|
||||
- lane directory has `opencode-sessions.json`
|
||||
- `launch-state.json` member has `runtimeRunId`, `runtimeSessionId`, and `bootstrapConfirmed: true`
|
||||
|
||||
If the bridge says bootstrap succeeded but the manifest has `entries: []`, the issue is evidence commit, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
|
||||
|
||||
OpenCode bridge ledger, if needed:
|
||||
|
||||
```bash
|
||||
LEDGER="$HOME/Library/Application Support/claude-agent-teams-ui/opencode-bridge/command-ledger.json"
|
||||
jq --arg team "$TEAM" '.data[] | select(.teamName == $team)' "$LEDGER" 2>/dev/null
|
||||
```
|
||||
|
||||
Live process checks:
|
||||
|
||||
```bash
|
||||
pgrep -af "opencode serve"
|
||||
ps -p <pid> -o pid,ppid,etime,command
|
||||
```
|
||||
|
||||
Do not kill all OpenCode processes as a debugging shortcut. First identify whether the pid belongs to the current team/lane. Some OpenCode temp `libopentui.dylib` files are held by live `opencode serve` processes and should only be cleaned after those processes are stopped.
|
||||
|
||||
## Messaging Debug Flow
|
||||
|
||||
Lead and teammates use different delivery paths:
|
||||
|
||||
- Lead reads stdin. Messages to lead go through `relayLeadInboxMessages()`.
|
||||
- Native teammates read their inbox files directly.
|
||||
- OpenCode teammates receive prompts through runtime delivery and must reply via `agent-teams_message_send`.
|
||||
- Teammate-to-user replies should appear in `inboxes/user.json` or app sent-message projections.
|
||||
|
||||
If a notification appears but the Messages UI does not show it:
|
||||
|
||||
```bash
|
||||
jq '.' "$TEAM_DIR/inboxes/user.json" 2>/dev/null
|
||||
jq '.' "$TEAM_DIR/sentMessages.json" 2>/dev/null
|
||||
```
|
||||
|
||||
Check `from`, `to`, `messageId`, `relayOfMessageId`, and `taskRefs`. Unknown authors should be rejected or normalized at the write boundary, not silently rendered as fake teammates.
|
||||
|
||||
For OpenCode "message saved but not delivered" cases, inspect the OpenCode prompt-delivery ledger and response proof. Do not synthesize visible replies in the frontend.
|
||||
|
||||
## Task And Work-Stall Debug Flow
|
||||
|
||||
For task stalls:
|
||||
|
||||
```bash
|
||||
TASK="<short-or-full-task-id>"
|
||||
rg -n "$TASK" "$TEAM_DIR/tasks" "$TEAM_DIR/inboxes" "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
|
||||
```
|
||||
|
||||
Important distinctions:
|
||||
- Delivery proof means the agent received the message.
|
||||
- Task progress proof means the agent made meaningful task progress.
|
||||
- A weak comment like "starting work" is not strong progress.
|
||||
- `task_add_comment` should be evaluated from the actual persisted comment text, not only from the tool call.
|
||||
|
||||
Task-stall monitor defaults:
|
||||
- General task-stall monitor is for all agents.
|
||||
- OpenCode direct remediation is provider-specific and should nudge the OpenCode owner first.
|
||||
- If OpenCode remediation is not accepted, fallback to lead alert.
|
||||
- Watchdog/remediation must not auto-start new OpenCode processes.
|
||||
|
||||
## Task Log Stream Debug Flow
|
||||
|
||||
Task Log Stream is a projection, not a separate source of truth.
|
||||
|
||||
For OpenCode tasks, a healthy stream should show native tool rows such as `read`, `bash`, `edit`, `write`, plus Agent Teams MCP rows. If it only shows `agent-teams_*` calls:
|
||||
- confirm the task has OpenCode attribution for the member/session
|
||||
- confirm the OpenCode transcript contains native tools inside the bounded task window
|
||||
- check whether the task was assigned after the native work happened
|
||||
- do not widen attribution so far that unrelated session work is pulled into the task
|
||||
|
||||
If Changes says "No file changes recorded" while native `write`/`edit` rows exist, inspect the ledger/backfill path. Task logs can show runtime tools even when `.board-task-changes/**` was not created.
|
||||
|
||||
## Safe Fix Checklist
|
||||
|
||||
Before changing launch or runtime logic:
|
||||
- Preserve stale-run, tombstone, stopped-team, and removed-member guards.
|
||||
- Do not make `member_briefing` runtime evidence.
|
||||
- Do not make delivery/watchdog auto-launch a fresh OpenCode lane.
|
||||
- Keep primary launch readiness separate from secondary OpenCode lane readiness.
|
||||
- Keep runtime evidence lane-scoped. Never let one OpenCode lane satisfy another lane.
|
||||
- Add a regression test for the exact state shape you found in `launch-state.json`.
|
||||
|
||||
Recommended verification:
|
||||
|
||||
```bash
|
||||
pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts
|
||||
pnpm vitest run test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts
|
||||
pnpm typecheck --pretty false
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Use narrower test commands first when editing a focused path, then run the broader suite that covers launch, delivery, and liveness.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Member Work Sync Control Plane Plan
|
||||
|
||||
**Status:** Proposed
|
||||
**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and opt-in Phase 2 nudge outbox/dispatcher/scheduler implemented
|
||||
**Scope:** Team management, task work synchronization, agent work coordination
|
||||
**Primary repo:** `claude_team`
|
||||
**Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller`
|
||||
|
|
@ -29,6 +29,21 @@ Phase 1 does not send nudges. It computes agenda/fingerprint/status, validates `
|
|||
|
||||
Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint churn and false positives are low.
|
||||
|
||||
Current implementation note:
|
||||
|
||||
- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface.
|
||||
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics.
|
||||
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
|
||||
- Phase 2 storage foundation is implemented as a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states.
|
||||
- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam gate and keeps UI/status reads passive.
|
||||
- Phase 2 nudge side effects are additionally disabled by default in production composition. Set `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1` only for isolated live validation. This keeps status/report/metrics active while guaranteeing that shadow-ready metrics cannot start inbox nudges by accident.
|
||||
- Dispatcher use case can run after queued reconcile and is also exposed through the facade when nudge side effects are explicitly enabled. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port.
|
||||
- Production busy revalidation is wired through a tool-activity busy signal adapter. Active or recently finished tool calls defer Phase 2 nudges instead of interrupting work.
|
||||
- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams only when nudge side effects are enabled. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write.
|
||||
- Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts.
|
||||
- Superseded-but-undelivered outbox items can be revived by a fresh queued reconcile for the same agenda fingerprint. Delivered nudges remain one-per-fingerprint.
|
||||
- Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
||||
|
||||
Patterns used:
|
||||
|
||||
- Kubernetes-style level-triggered reconcile: recompute from current desired/current state instead of trusting events.
|
||||
|
|
@ -1136,7 +1151,7 @@ Validation result contract:
|
|||
|
||||
```ts
|
||||
export type MemberWorkSyncReportValidationReason =
|
||||
| 'feature_disabled'
|
||||
| 'capability_unavailable'
|
||||
| 'team_inactive'
|
||||
| 'member_inactive'
|
||||
| 'reserved_author'
|
||||
|
|
@ -1349,7 +1364,7 @@ Expired leases are ignored by `SyncDecisionPolicy`.
|
|||
|
||||
### 10.4 Shadow Would-Nudge Semantics
|
||||
|
||||
Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send.
|
||||
Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send. Production composition enforces this by default by not wiring `outboxStore`/`inboxNudge` unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1`.
|
||||
|
||||
`wouldNudge` is true only when all are true:
|
||||
|
||||
|
|
@ -1866,26 +1881,26 @@ Rules:
|
|||
- In Phase 1, missing `member_work_sync_report` must not block team launch.
|
||||
- If the tool is missing, omit work-sync instructions from `task_briefing`/`member_briefing`.
|
||||
- If the tool exists but app validation bridge is unavailable, return `pending_validation`.
|
||||
- If app says feature disabled, return `feature_disabled`.
|
||||
- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tool is optional unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_REQUIRE_MCP_TOOL=true`.
|
||||
- Do not add a runtime/env flag to require the tool in Phase 1.
|
||||
- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tools remain optional compatibility capabilities.
|
||||
|
||||
Suggested rollout gate:
|
||||
Important distinction:
|
||||
|
||||
```text
|
||||
CLAUDE_TEAM_MEMBER_WORK_SYNC_REQUIRE_MCP_TOOL=false
|
||||
capability-gated means "use it if both sides expose it".
|
||||
feature-flagged means "runtime branch changes behavior based on env/config".
|
||||
```
|
||||
|
||||
Default `false` until Phase 1 has shipped across both repos.
|
||||
Phase 1 uses capability gating only. That avoids permanent `new vs legacy` branches while still supporting mixed repo versions during development.
|
||||
|
||||
Compatibility matrix:
|
||||
|
||||
| claude_team | orchestrator/controller | Expected behavior |
|
||||
|---|---|---|
|
||||
| no feature | no tool | no work-sync surface |
|
||||
| feature enabled | no tool | status/reconcile only, no report instruction |
|
||||
| feature enabled | tool exists, no app bridge | pending intent only |
|
||||
| feature enabled | tool exists, app bridge live | full report validation |
|
||||
| feature disabled | tool exists | tool returns `feature_disabled`, no writes |
|
||||
| app has feature | no tool | status/reconcile only, no report instruction |
|
||||
| app has feature | tool exists, no app bridge | pending intent only |
|
||||
| app has feature | tool exists, app bridge live | full report validation |
|
||||
|
||||
### 13.1 Current Agenda Read Surface
|
||||
|
||||
|
|
@ -2869,8 +2884,7 @@ Details dialog can show:
|
|||
- missing `member_work_sync_report` does not fail OpenCode readiness in Phase 1;
|
||||
- work-sync instructions are omitted when the tool is unavailable;
|
||||
- tool available + app bridge unavailable returns `pending_validation`;
|
||||
- feature disabled returns `feature_disabled` and writes no intents;
|
||||
- optional require-tool gate can fail readiness when explicitly enabled.
|
||||
- no runtime flag is needed to require the tool in Phase 1.
|
||||
|
||||
### 20.4 Controller Tests
|
||||
|
||||
|
|
@ -2888,7 +2902,7 @@ In `agent-teams-controller`:
|
|||
- returns structured stale fingerprint response;
|
||||
- returns `pendingValidation` instead of accepted lease when app validator is unavailable;
|
||||
- pending validation intent replay does not update lease until app accepts;
|
||||
- disabled feature returns `feature_disabled` and does not write intents;
|
||||
- capability unavailable returns `capability_unavailable` and does not write accepted reports;
|
||||
- exposes current fingerprint through the chosen read surface;
|
||||
- does not write task comments or messages.
|
||||
|
||||
|
|
@ -2976,6 +2990,7 @@ Check:
|
|||
- stale report rate;
|
||||
- invalid caught-up attempts;
|
||||
- how many nudges Phase 2 would send.
|
||||
- `phase2Readiness.state` remains `collecting_shadow_data` until the sample is large enough, `blocked` if rates are noisy, and only then `shadow_ready`.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
|
|
@ -2999,6 +3014,15 @@ Includes:
|
|||
- per-member token bucket;
|
||||
- shared cooldown with watchdog.
|
||||
|
||||
Implemented safety constraints:
|
||||
|
||||
- only queued reconciles plan outbox rows;
|
||||
- read-only diagnostics never plan outbox rows;
|
||||
- outbox planning requires `phase2Readiness.state === "shadow_ready"`;
|
||||
- dispatch revalidates lifecycle, current status, current fingerprint, readiness, busy state, rate limit, and watchdog cooldown immediately before inbox insert;
|
||||
- scheduled dispatch lists lifecycle-active teams only, not all stored teams;
|
||||
- undelivered `superseded` rows can be revived by a later fresh reconcile for the same fingerprint, while `delivered` rows remain one-per-fingerprint.
|
||||
|
||||
### Phase 3: Provider Accelerators
|
||||
|
||||
`🎯 8 🛡️ 8 🧠 5`, `300-600 LOC`.
|
||||
|
|
@ -3012,57 +3036,59 @@ Includes:
|
|||
|
||||
No accelerator is proof.
|
||||
|
||||
Current implementation:
|
||||
|
||||
- tool-finish enqueue and tool-activity busy suppression are implemented through `TeamChangeEvent` and the feature-owned busy signal;
|
||||
- Claude Stop hook and OpenCode turn-settled hooks are intentionally not wired yet because the current feature boundary does not expose one authoritative cross-provider "turn settled and idle" signal. Adding an adapter around prompt text, idle notifications, or provider-specific transcript heuristics would be less reliable than the current tool-finish + scheduled reconcile path;
|
||||
- manual "sync now" remains optional because details/status reads are passive by design, and explicit manual nudges should reuse the existing outbox/dispatcher instead of bypassing readiness guards.
|
||||
|
||||
---
|
||||
|
||||
## 22. Feature Gates
|
||||
## 22. Runtime Defaults And No Feature Flags
|
||||
|
||||
Phase 1:
|
||||
Phase 1 shipped without feature flags.
|
||||
|
||||
```text
|
||||
CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED=true
|
||||
CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=true
|
||||
Reason:
|
||||
|
||||
- Phase 1 has no nudges, no inbox writes, no task mutation, and no runtime restart behavior.
|
||||
- Adding feature flags for passive status/report validation creates extra branches and makes failures harder to reason about.
|
||||
- The safe boundary is architectural, not configurational: passive status/report validation stays independent from Phase 2 side effects.
|
||||
|
||||
Runtime defaults:
|
||||
|
||||
| Behavior | Default | Why |
|
||||
|---|---:|---|
|
||||
| agenda/fingerprint/status computation | on | passive, deterministic, app-owned |
|
||||
| `member_work_sync_status` | on | read-only diagnostics |
|
||||
| `member_work_sync_report` | on | server-validated, no board mutation |
|
||||
| pending report intent fallback | on only when identity is not terminally invalid | compatibility with old app/runtime boundaries |
|
||||
| outbox planning | on only for queued reconciles and only when `phase2Readiness=shadow_ready` | prevents status reads from causing side effects |
|
||||
| scheduled nudge dispatch | on only for lifecycle-active teams | stopped teams must not claim or supersede pending nudges |
|
||||
| inbox nudge writes | guarded by dispatcher revalidation | lifecycle, current fingerprint, readiness, busy signal, rate limit, and watchdog cooldown are checked immediately before write |
|
||||
|
||||
Do not add:
|
||||
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED`
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY`
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED`
|
||||
|
||||
If Phase 1 needs to be disabled during development, revert or patch the narrow composition wiring. Do not add a permanent product branch for a passive feature.
|
||||
|
||||
Phase 2 policy:
|
||||
|
||||
- Phase 2 is implemented as a separate outbox/dispatcher/scheduler path, not as hidden branching inside passive diagnostics.
|
||||
- Phase 2 does not bypass shadow readiness. If metrics are noisy, the planner returns `phase2_not_ready`.
|
||||
- Phase 2 uses constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch.
|
||||
|
||||
Phase 2 runtime constants can be normal typed defaults, not feature gates:
|
||||
|
||||
```ts
|
||||
const MEMBER_WORK_SYNC_QUIET_WINDOW_MS = 90_000;
|
||||
const MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS = 10 * 60_000;
|
||||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- enabled can default `true` only if Phase 1 is read/status-only;
|
||||
- shadow-only must default `true`;
|
||||
- Phase 2 nudges default `false` until explicitly validated.
|
||||
|
||||
Gate behavior:
|
||||
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED=false` disables queue, reconcile, status writes, and report acceptance. The MCP report tool should return `feature_disabled`.
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=true` allows reconcile/status/report validation but forbids outbox and inbox writes.
|
||||
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=false` is allowed only after Phase 2 implementation and metrics review.
|
||||
- Report intent recording should also honor `ENABLED=false`; do not write intent files when the feature is explicitly disabled.
|
||||
- Read surfaces can include `"feature": "disabled"` when disabled, but should not instruct agents to call the report tool.
|
||||
|
||||
Phase 2:
|
||||
|
||||
```text
|
||||
CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=false
|
||||
CLAUDE_TEAM_MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR=2
|
||||
CLAUDE_TEAM_MEMBER_WORK_SYNC_QUIET_WINDOW_MS=90000
|
||||
CLAUDE_TEAM_MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS=600000
|
||||
```
|
||||
|
||||
Recommended defaults by phase:
|
||||
|
||||
| Gate | Phase 1 default | Phase 2 default after metrics |
|
||||
|---|---:|---:|
|
||||
| `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED` | `true` | `true` |
|
||||
| `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY` | `true` | `false` only after manual enable |
|
||||
| `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED` | `false` | `false` until explicitly flipped |
|
||||
| report tool enabled | `true` when feature enabled | `true` |
|
||||
| report intent fallback | `true` when feature enabled | `true` |
|
||||
|
||||
Kill-switch expectations:
|
||||
|
||||
- turning `ENABLED=false` should stop queue processing within one event-loop tick;
|
||||
- pending outbox items must not dispatch while disabled;
|
||||
- report tool should return a structured disabled response;
|
||||
- status read APIs may still return last known status marked stale/disabled;
|
||||
- no feature flag should change task board state directly.
|
||||
If we ever need an emergency kill switch for production nudges, it must only wrap the Phase 2 dispatcher. It must not disable agenda/status/report validation.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -3401,7 +3427,7 @@ Step order:
|
|||
2. Extend current agenda read surface.
|
||||
- Prefer `task_briefing.workSync`.
|
||||
- Include compact agenda preview, `agendaFingerprint`, state, and `reportToken`.
|
||||
- Omit report instructions when tool is unavailable or feature disabled.
|
||||
- Omit report instructions when tool or app validation capability is unavailable.
|
||||
- Keep old `task_briefing` fields unchanged.
|
||||
|
||||
3. Implement report validator.
|
||||
|
|
|
|||
40
docs/team-management/member-work-sync-debugging.md
Normal file
40
docs/team-management/member-work-sync-debugging.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Member Work Sync Debugging
|
||||
|
||||
`member-work-sync` stores member-scoped control-plane state under each team member:
|
||||
|
||||
```text
|
||||
~/.claude/teams/<team>/members/<member-key>/.member-work-sync/
|
||||
status.json
|
||||
reports.json
|
||||
outbox.json
|
||||
journal.jsonl
|
||||
```
|
||||
|
||||
`member-key` is the normalized, percent-encoded member name. The canonical name is stored in:
|
||||
|
||||
```text
|
||||
~/.claude/teams/<team>/members/<member-key>/member.meta.json
|
||||
```
|
||||
|
||||
Use the journal for local debugging:
|
||||
|
||||
```bash
|
||||
tail -f ~/.claude/teams/<team>/members/<member-key>/.member-work-sync/journal.jsonl
|
||||
```
|
||||
|
||||
The journal is append-only JSONL and records sync decisions, not raw agent transcripts. Useful events:
|
||||
|
||||
- `reconcile_started`, `agenda_loaded`, `decision_made`, `status_written`
|
||||
- `report_received`, `report_accepted`, `report_rejected`
|
||||
- `nudge_planned`, `nudge_delivered`, `nudge_skipped`, `nudge_retryable`, `nudge_superseded`
|
||||
- `member_busy`, `watchdog_cooldown_active`, `team_inactive`, `legacy_fallback_used`
|
||||
|
||||
Team-level shared/index state remains under:
|
||||
|
||||
```text
|
||||
~/.claude/teams/<team>/.member-work-sync/
|
||||
indexes/
|
||||
report-token-secret.json
|
||||
```
|
||||
|
||||
The indexes are implementation details used to avoid scanning every member directory on the hot path.
|
||||
2700
docs/team-management/member-work-sync-opencode-turn-settled-plan.md
Normal file
2700
docs/team-management/member-work-sync-opencode-turn-settled-plan.md
Normal file
File diff suppressed because it is too large
Load diff
1767
docs/team-management/member-work-sync-runtime-stop-hook-plan.md
Normal file
1767
docs/team-management/member-work-sync-runtime-stop-hook-plan.md
Normal file
File diff suppressed because it is too large
Load diff
1919
docs/team-management/task-log-stream-candidate-selector-plan.md
Normal file
1919
docs/team-management/task-log-stream-candidate-selector-plan.md
Normal file
File diff suppressed because it is too large
Load diff
8
mcp-server/src/agent-teams-controller.d.ts
vendored
8
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -102,6 +102,11 @@ declare module 'agent-teams-controller' {
|
|||
runtimeHeartbeat(flags: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ControllerWorkSyncApi {
|
||||
memberWorkSyncStatus(flags: Record<string, unknown>): Promise<unknown>;
|
||||
memberWorkSyncReport(flags: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface AgentTeamsController {
|
||||
tasks: ControllerTaskApi;
|
||||
kanban: ControllerKanbanApi;
|
||||
|
|
@ -111,6 +116,7 @@ declare module 'agent-teams-controller' {
|
|||
maintenance: ControllerMaintenanceApi;
|
||||
crossTeam: ControllerCrossTeamApi;
|
||||
runtime: ControllerRuntimeApi;
|
||||
workSync: ControllerWorkSyncApi;
|
||||
}
|
||||
|
||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
||||
|
|
@ -143,6 +149,7 @@ declare module 'agent-teams-controller' {
|
|||
| 'message'
|
||||
| 'process'
|
||||
| 'runtime'
|
||||
| 'workSync'
|
||||
| 'crossTeam';
|
||||
|
||||
export interface AgentTeamsMcpToolGroup {
|
||||
|
|
@ -159,6 +166,7 @@ declare module 'agent-teams-controller' {
|
|||
export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[];
|
||||
export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[];
|
||||
export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[];
|
||||
export const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES: readonly string[];
|
||||
export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[];
|
||||
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
|
||||
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||
|
|
|
|||
|
|
@ -10,10 +10,17 @@ const { createController } = controllerModule;
|
|||
|
||||
const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
||||
|
||||
type WorkSyncCapableController = ReturnType<typeof createController> & {
|
||||
workSync: {
|
||||
memberWorkSyncStatus(flags: Record<string, unknown>): Promise<unknown>;
|
||||
memberWorkSyncReport(flags: Record<string, unknown>): Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
/** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */
|
||||
export const agentBlocks = controllerModule.agentBlocks;
|
||||
|
||||
export function getController(teamName: string, claudeDir?: string) {
|
||||
export function getController(teamName: string, claudeDir?: string): WorkSyncCapableController {
|
||||
const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim();
|
||||
let resolvedClaudeDir = claudeDir;
|
||||
if (forcedClaudeDir) {
|
||||
|
|
@ -24,5 +31,5 @@ export function getController(teamName: string, claudeDir?: string) {
|
|||
teamName,
|
||||
...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}),
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
}) as WorkSyncCapableController;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { registerReviewTools } from './reviewTools';
|
|||
import { registerRuntimeTools } from './runtimeTools';
|
||||
import { registerTaskTools } from './taskTools';
|
||||
import { registerTeamTools } from './teamTools';
|
||||
import { registerWorkSyncTools } from './workSyncTools';
|
||||
|
||||
const REGISTRATION_BY_GROUP = {
|
||||
team: registerTeamTools,
|
||||
|
|
@ -24,6 +25,7 @@ const REGISTRATION_BY_GROUP = {
|
|||
message: registerMessageTools,
|
||||
process: registerProcessTools,
|
||||
runtime: registerRuntimeTools,
|
||||
workSync: registerWorkSyncTools,
|
||||
crossTeam: registerCrossTeamTools,
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
86
mcp-server/src/tools/workSyncTools.ts
Normal file
86
mcp-server/src/tools/workSyncTools.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const controlContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
controlUrl: z.string().optional(),
|
||||
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
|
||||
};
|
||||
|
||||
const reportStateSchema = z.enum(['still_working', 'blocked', 'caught_up']);
|
||||
|
||||
export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'member_work_sync_status',
|
||||
description:
|
||||
'Read your current actionable-work agenda and agendaFingerprint before reporting whether you are still working, blocked, or caught up.',
|
||||
parameters: z.object({
|
||||
...controlContextSchema,
|
||||
memberName: z.string().min(1).optional(),
|
||||
from: z.string().min(1).optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, memberName, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({
|
||||
...(memberName ? { memberName } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'member_work_sync_report',
|
||||
description:
|
||||
'Report your validated work-sync state for the current agendaFingerprint. This never completes tasks. Use still_working while actively continuing, blocked only when the board has blocker evidence, and caught_up only when the status agenda is empty.',
|
||||
parameters: z.object({
|
||||
...controlContextSchema,
|
||||
memberName: z.string().min(1).optional(),
|
||||
from: z.string().min(1).optional(),
|
||||
state: reportStateSchema,
|
||||
agendaFingerprint: z.string().min(1),
|
||||
reportToken: z.string().min(1),
|
||||
taskIds: z.array(z.string().min(1)).optional(),
|
||||
note: z.string().optional(),
|
||||
leaseTtlMs: z.number().int().min(60000).max(3600000).optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
controlUrl,
|
||||
waitTimeoutMs,
|
||||
memberName,
|
||||
from,
|
||||
state,
|
||||
agendaFingerprint,
|
||||
reportToken,
|
||||
taskIds,
|
||||
note,
|
||||
leaseTtlMs,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).workSync.memberWorkSyncReport({
|
||||
...(memberName ? { memberName } : {}),
|
||||
...(from ? { from } : {}),
|
||||
state,
|
||||
agendaFingerprint,
|
||||
reportToken,
|
||||
...(taskIds ? { taskIds } : {}),
|
||||
...(note ? { note } : {}),
|
||||
...(leaseTtlMs ? { leaseTtlMs } : {}),
|
||||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -400,6 +400,97 @@ describe('agent-teams-mcp tools', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('forwards member work sync MCP tools through the app validator bridge', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
if (method === 'GET' && url === '/api/teams/alpha/member-work-sync/alice') {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:abc',
|
||||
items: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: ['no_current_report'],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') {
|
||||
return { body: { accepted: true, code: 'accepted', status: body } };
|
||||
}
|
||||
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||
});
|
||||
|
||||
try {
|
||||
const status = parseJsonToolResult(
|
||||
await getTool('member_work_sync_status').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
from: 'alice',
|
||||
})
|
||||
);
|
||||
expect(status.state).toBe('needs_sync');
|
||||
|
||||
const report = parseJsonToolResult(
|
||||
await getTool('member_work_sync_report').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
taskIds: ['task-1'],
|
||||
note: 'Still working',
|
||||
leaseTtlMs: 120000,
|
||||
})
|
||||
);
|
||||
expect(report.accepted).toBe(true);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/api/teams/alpha/member-work-sync/alice',
|
||||
body: undefined,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/teams/alpha/member-work-sync/report',
|
||||
body: {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test.token',
|
||||
taskIds: ['task-1'],
|
||||
note: 'Still working',
|
||||
leaseTtlMs: 120000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('discovers the control endpoint from the published state file', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
},
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000125,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000002,
|
||||
"cache_read_input_token_cost": 1e-7,
|
||||
"input_cost_per_token": 0.000001,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
},
|
||||
"anthropic.claude-haiku-4-5@20251001": {
|
||||
"cache_creation_input_token_cost": 0.00000125,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000002,
|
||||
"cache_read_input_token_cost": 1e-7,
|
||||
"input_cost_per_token": 0.000001,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -261,6 +263,7 @@
|
|||
},
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
"input_cost_per_token": 0.000005,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -288,6 +291,7 @@
|
|||
},
|
||||
"anthropic.claude-opus-4-6-v1": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
"input_cost_per_token": 0.000005,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -317,6 +321,7 @@
|
|||
},
|
||||
"global.anthropic.claude-opus-4-6-v1": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
"input_cost_per_token": 0.000005,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -346,6 +351,7 @@
|
|||
},
|
||||
"us.anthropic.claude-opus-4-6-v1": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
||||
"cache_read_input_token_cost": 5.5e-7,
|
||||
"input_cost_per_token": 0.0000055,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -433,6 +439,7 @@
|
|||
},
|
||||
"anthropic.claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
"input_cost_per_token": 0.000005,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -477,6 +484,7 @@
|
|||
},
|
||||
"global.anthropic.claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
"input_cost_per_token": 0.000005,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -507,6 +515,7 @@
|
|||
},
|
||||
"us.anthropic.claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
||||
"cache_read_input_token_cost": 5.5e-7,
|
||||
"input_cost_per_token": 0.0000055,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -597,6 +606,7 @@
|
|||
},
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000006,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
"input_cost_per_token": 0.000003,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -625,6 +635,7 @@
|
|||
},
|
||||
"global.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000006,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
"input_cost_per_token": 0.000003,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -653,6 +664,7 @@
|
|||
},
|
||||
"us.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.0000066,
|
||||
"cache_read_input_token_cost": 3.3e-7,
|
||||
"input_cost_per_token": 0.0000033,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -767,11 +779,13 @@
|
|||
},
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000006,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
"input_cost_per_token": 0.000003,
|
||||
"input_cost_per_token_above_200k_tokens": 0.000006,
|
||||
"output_cost_per_token_above_200k_tokens": 0.0000225,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -1963,12 +1977,12 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-opus-4-7-20260416": {
|
||||
|
|
@ -1997,12 +2011,12 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"provider_specific_entry": {
|
||||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
|
|
@ -2523,6 +2537,11 @@
|
|||
"supports_url_context": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.035,
|
||||
"search_context_size_medium": 0.035,
|
||||
"search_context_size_high": 0.035
|
||||
},
|
||||
"supports_service_tier": true
|
||||
},
|
||||
"gemini-2.5-flash-lite": {
|
||||
|
|
@ -2569,6 +2588,11 @@
|
|||
"supports_url_context": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.035,
|
||||
"search_context_size_medium": 0.035,
|
||||
"search_context_size_high": 0.035
|
||||
},
|
||||
"supports_service_tier": true
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
|
|
@ -2615,6 +2639,11 @@
|
|||
"supports_video_input": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.035,
|
||||
"search_context_size_medium": 0.035,
|
||||
"search_context_size_high": 0.035
|
||||
},
|
||||
"supports_service_tier": true
|
||||
},
|
||||
"gmi/anthropic/claude-opus-4.5": {
|
||||
|
|
@ -2663,11 +2692,13 @@
|
|||
},
|
||||
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000006,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
"input_cost_per_token": 0.000003,
|
||||
"input_cost_per_token_above_200k_tokens": 0.000006,
|
||||
"output_cost_per_token_above_200k_tokens": 0.0000225,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -2724,6 +2755,7 @@
|
|||
},
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000125,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000002,
|
||||
"cache_read_input_token_cost": 1e-7,
|
||||
"input_cost_per_token": 0.000001,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -3036,7 +3068,9 @@
|
|||
"supported_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_tool_choice": false
|
||||
"supports_tool_choice": false,
|
||||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 1024
|
||||
},
|
||||
"gradient_ai/anthropic-claude-3.5-haiku": {
|
||||
"input_cost_per_token": 8e-7,
|
||||
|
|
@ -3050,7 +3084,9 @@
|
|||
"supported_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_tool_choice": false
|
||||
"supports_tool_choice": false,
|
||||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 1024
|
||||
},
|
||||
"gradient_ai/anthropic-claude-3.5-sonnet": {
|
||||
"input_cost_per_token": 0.000003,
|
||||
|
|
@ -3064,7 +3100,9 @@
|
|||
"supported_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_tool_choice": false
|
||||
"supports_tool_choice": false,
|
||||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 1024
|
||||
},
|
||||
"gradient_ai/anthropic-claude-3.7-sonnet": {
|
||||
"input_cost_per_token": 0.000003,
|
||||
|
|
@ -3078,7 +3116,9 @@
|
|||
"supported_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_tool_choice": false
|
||||
"supports_tool_choice": false,
|
||||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 1024
|
||||
},
|
||||
"jp.anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
|
|
@ -3138,12 +3178,14 @@
|
|||
"input_cost_per_image": 0.0004,
|
||||
"input_cost_per_token": 2.5e-7,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_tokens": 200000,
|
||||
"max_tokens": 4096,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 0.00000125,
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 4096
|
||||
},
|
||||
"openrouter/anthropic/claude-3.5-sonnet": {
|
||||
"input_cost_per_token": 0.000003,
|
||||
|
|
@ -3468,6 +3510,7 @@
|
|||
},
|
||||
"us.anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.000001375,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.0000022,
|
||||
"cache_read_input_token_cost": 1.1e-7,
|
||||
"input_cost_per_token": 0.0000011,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -3619,11 +3662,13 @@
|
|||
},
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.0000066,
|
||||
"cache_read_input_token_cost": 3.3e-7,
|
||||
"input_cost_per_token": 0.0000033,
|
||||
"input_cost_per_token_above_200k_tokens": 0.0000066,
|
||||
"output_cost_per_token_above_200k_tokens": 0.00002475,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
|
||||
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000132,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 200000,
|
||||
|
|
@ -3724,6 +3769,7 @@
|
|||
},
|
||||
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.000006875,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
||||
"cache_read_input_token_cost": 5.5e-7,
|
||||
"input_cost_per_token": 0.0000055,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
@ -3751,6 +3797,7 @@
|
|||
},
|
||||
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
"input_cost_per_token": 0.000005,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.13",
|
||||
"sourceRef": "v0.0.13",
|
||||
"version": "0.0.15",
|
||||
"sourceRef": "v0.0.15",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.13.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.15.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.13.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.15.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.13.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.15.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.13.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.15.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
|
||||
|
|
|
|||
|
|
@ -6,29 +6,53 @@ import {
|
|||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
import type { AppState } from '@renderer/store/types';
|
||||
|
||||
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
||||
interface GraphMemberPopoverContext {
|
||||
teamData:
|
||||
| (NonNullable<ReturnType<typeof selectTeamDataForName>> & {
|
||||
members: ReturnType<typeof selectResolvedMembersForTeamName>;
|
||||
messageFeed: [];
|
||||
})
|
||||
| null;
|
||||
teamMembers: ReturnType<typeof selectResolvedMembersForTeamName>;
|
||||
spawnEntry: AppState['memberSpawnStatusesByTeam'][string][string] | undefined;
|
||||
leadActivity: AppState['leadActivityByTeam'][string] | undefined;
|
||||
progress: ReturnType<typeof getCurrentProvisioningProgressForTeam> | null;
|
||||
memberSpawnSnapshot: AppState['memberSpawnSnapshotsByTeam'][string] | undefined;
|
||||
memberSpawnStatuses: AppState['memberSpawnStatusesByTeam'][string] | undefined;
|
||||
}
|
||||
|
||||
function selectGraphMemberPopoverContext(
|
||||
state: AppState,
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): GraphMemberPopoverContext {
|
||||
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
|
||||
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
|
||||
|
||||
return {
|
||||
teamData: snapshot
|
||||
? {
|
||||
...snapshot,
|
||||
members: teamMembers,
|
||||
messageFeed: [],
|
||||
}
|
||||
: null,
|
||||
teamMembers,
|
||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useGraphMemberPopoverContext(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): ReturnType<typeof selectGraphMemberPopoverContext> {
|
||||
return useStore(
|
||||
useShallow((state) => {
|
||||
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
|
||||
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
|
||||
|
||||
return {
|
||||
teamData: snapshot
|
||||
? {
|
||||
...snapshot,
|
||||
members: teamMembers,
|
||||
messageFeed: [],
|
||||
}
|
||||
: null,
|
||||
teamMembers,
|
||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
};
|
||||
})
|
||||
useShallow((state) => selectGraphMemberPopoverContext(state, teamName, memberName))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,6 +289,15 @@ export const GraphActivityHud = ({
|
|||
const handleMessageClick = useCallback((item: TimelineItem) => {
|
||||
setExpandedItem(item);
|
||||
}, []);
|
||||
const handleMessageKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>, item: TimelineItem): void => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleMessageClick(item);
|
||||
}
|
||||
},
|
||||
[handleMessageClick]
|
||||
);
|
||||
|
||||
const handleMemberClick = useCallback(
|
||||
(member: ResolvedTeamMember) => {
|
||||
|
|
@ -360,6 +369,52 @@ export const GraphActivityHud = ({
|
|||
};
|
||||
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
||||
|
||||
const renderLaneEntry = useCallback(
|
||||
(entry: InlineActivityEntry, index: number): React.JSX.Element => {
|
||||
const messageKey = toMessageKey(entry.message);
|
||||
const timelineItem: TimelineItem = {
|
||||
type: 'message',
|
||||
message: entry.message,
|
||||
};
|
||||
const isUnread = !entry.message.read && !readSet.has(messageKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.graphItem.id}
|
||||
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onKeyDown={(event) => handleMessageKeyDown(event, timelineItem)}
|
||||
>
|
||||
<GraphActivityCard
|
||||
message={entry.message}
|
||||
teamName={teamName}
|
||||
messageContext={messageContext}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
isUnread={isUnread}
|
||||
zebraShade={index % 2 === 1}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
handleMessageClick,
|
||||
handleMessageKeyDown,
|
||||
messageContext,
|
||||
onOpenMemberProfile,
|
||||
onOpenTaskDetail,
|
||||
readSet,
|
||||
teamColorByName,
|
||||
teamName,
|
||||
teamNames,
|
||||
]
|
||||
);
|
||||
|
||||
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -421,43 +476,7 @@ export const GraphActivityHud = ({
|
|||
No recent activity
|
||||
</div>
|
||||
) : null}
|
||||
{lane.entries.map((entry, index) => {
|
||||
const messageKey = toMessageKey(entry.message);
|
||||
const timelineItem: TimelineItem = {
|
||||
type: 'message',
|
||||
message: entry.message,
|
||||
};
|
||||
const isUnread = !entry.message.read && !readSet.has(messageKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.graphItem.id}
|
||||
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleMessageClick(timelineItem);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GraphActivityCard
|
||||
message={entry.message}
|
||||
teamName={teamName}
|
||||
messageContext={messageContext}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
isUnread={isUnread}
|
||||
zebraShade={index % 2 === 1}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{lane.entries.map(renderLaneEntry)}
|
||||
|
||||
{lane.overflowCount > 0 ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import { GraphNodePopover } from './GraphNodePopover';
|
|||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
GraphDomainRef,
|
||||
GraphEventPort,
|
||||
TransientHandoffCard,
|
||||
} from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
|
|
@ -149,7 +153,7 @@ export const TeamGraphOverlay = ({
|
|||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}) => {
|
||||
cards: import('@claude-teams/agent-graph').TransientHandoffCard[];
|
||||
cards: TransientHandoffCard[];
|
||||
time: number;
|
||||
};
|
||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import { GraphNodePopover } from './GraphNodePopover';
|
|||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
GraphDomainRef,
|
||||
GraphEventPort,
|
||||
TransientHandoffCard,
|
||||
} from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
|
|
@ -169,7 +173,7 @@ export const TeamGraphTab = ({
|
|||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}) => {
|
||||
cards: import('@claude-teams/agent-graph').TransientHandoffCard[];
|
||||
cards: TransientHandoffCard[];
|
||||
time: number;
|
||||
};
|
||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
private mutationQueue: Promise<void> = Promise.resolve();
|
||||
private mutationQueueRelease: (() => void) | null = null;
|
||||
private activeMutationCount = 0;
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly logger: LoggerPort,
|
||||
|
|
@ -290,8 +291,12 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
}
|
||||
|
||||
async getSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||
if (this.snapshotCache && Date.now() - this.snapshotObservedAt <= SNAPSHOT_CACHE_TTL_MS) {
|
||||
return deepClone(this.snapshotCache);
|
||||
const cached = this.getCachedSnapshotForOptions({
|
||||
includeRateLimits: false,
|
||||
forceRefreshToken: false,
|
||||
});
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return this.refreshSnapshot();
|
||||
|
|
@ -301,10 +306,13 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
includeRateLimits?: boolean;
|
||||
forceRefreshToken?: boolean;
|
||||
}): Promise<CodexAccountSnapshotDto> {
|
||||
this.pendingRefreshOptions = mergeRefreshOptions(
|
||||
this.pendingRefreshOptions,
|
||||
normalizeRefreshOptions(options)
|
||||
);
|
||||
const normalizedOptions = normalizeRefreshOptions(options);
|
||||
const cached = this.getCachedSnapshotForOptions(normalizedOptions);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
this.pendingRefreshOptions = mergeRefreshOptions(this.pendingRefreshOptions, normalizedOptions);
|
||||
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = this.drainRefreshQueue().finally(() => {
|
||||
|
|
@ -386,6 +394,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
await this.loginSessionManager.dispose();
|
||||
this.listeners.clear();
|
||||
this.snapshotCache = null;
|
||||
|
|
@ -410,7 +419,8 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
this.pendingRefreshOptions = null;
|
||||
await this.mutationQueue.catch(() => undefined);
|
||||
|
||||
lastSnapshot = await this.loadSnapshot(nextOptions);
|
||||
lastSnapshot =
|
||||
this.getCachedSnapshotForOptions(nextOptions) ?? (await this.loadSnapshot(nextOptions));
|
||||
}
|
||||
|
||||
if (!lastSnapshot) {
|
||||
|
|
@ -464,12 +474,17 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
let accountPayload = this.lastKnownAccount?.payload ?? null;
|
||||
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
|
||||
let runtimeContext = createRuntimeContext(binaryPath, null);
|
||||
const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now);
|
||||
const shouldRequestRateLimits =
|
||||
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
|
||||
let rateLimitsReadFailure: unknown | null = null;
|
||||
|
||||
try {
|
||||
const accountResult = await this.appServerClient.readAccount({
|
||||
const accountResult = await this.appServerClient.readAccountSnapshot({
|
||||
binaryPath,
|
||||
env,
|
||||
refreshToken: options?.forceRefreshToken ?? false,
|
||||
includeRateLimits: shouldRequestRateLimits,
|
||||
});
|
||||
runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome);
|
||||
if (runtimeContext.codexHome) {
|
||||
|
|
@ -498,6 +513,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
observedAt: now,
|
||||
};
|
||||
}
|
||||
if (accountResult.rateLimits?.ok) {
|
||||
this.lastKnownRateLimits = {
|
||||
payload: accountResult.rateLimits.payload,
|
||||
observedAt: now,
|
||||
};
|
||||
} else if (accountResult.rateLimits) {
|
||||
rateLimitsReadFailure = accountResult.rateLimits.error;
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = classifyAppServerFailure(error);
|
||||
appServerState = failure.appServerState;
|
||||
|
|
@ -525,35 +548,18 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
|
||||
let rateLimits: CodexRateLimitSnapshotDto | null = null;
|
||||
const shouldLoadRateLimits =
|
||||
options?.includeRateLimits === true ||
|
||||
(this.lastKnownRateLimits !== null &&
|
||||
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS);
|
||||
options?.includeRateLimits === true || this.hasFreshRateLimits(now);
|
||||
|
||||
if (shouldLoadRateLimits) {
|
||||
try {
|
||||
if (
|
||||
this.lastKnownRateLimits &&
|
||||
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS
|
||||
) {
|
||||
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
|
||||
} else {
|
||||
const rateLimitsPayload = await this.appServerClient.readRateLimits({
|
||||
binaryPath,
|
||||
env,
|
||||
});
|
||||
this.lastKnownRateLimits = {
|
||||
payload: rateLimitsPayload,
|
||||
observedAt: now,
|
||||
};
|
||||
rateLimits = asRateLimits(rateLimitsPayload.rateLimits);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) {
|
||||
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
|
||||
} else if (rateLimitsReadFailure) {
|
||||
this.logger.warn('codex account rate limits refresh failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error:
|
||||
rateLimitsReadFailure instanceof Error
|
||||
? rateLimitsReadFailure.message
|
||||
: String(rateLimitsReadFailure),
|
||||
});
|
||||
rateLimits = this.lastKnownRateLimits
|
||||
? asRateLimits(this.lastKnownRateLimits.payload.rateLimits)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -590,6 +596,10 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
}
|
||||
|
||||
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
|
||||
if (this.disposed) {
|
||||
return deepClone(nextSnapshot);
|
||||
}
|
||||
|
||||
this.snapshotCache = deepClone(nextSnapshot);
|
||||
this.snapshotObservedAt = Date.now();
|
||||
const snapshot = deepClone(nextSnapshot);
|
||||
|
|
@ -600,6 +610,36 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
return snapshot;
|
||||
}
|
||||
|
||||
private getCachedSnapshotForOptions(
|
||||
options: CodexSnapshotRefreshOptions
|
||||
): CodexAccountSnapshotDto | null {
|
||||
if (
|
||||
this.hasPendingMutation() ||
|
||||
options.forceRefreshToken ||
|
||||
!this.snapshotCache ||
|
||||
Date.now() - this.snapshotObservedAt > SNAPSHOT_CACHE_TTL_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.includeRateLimits && !this.hasFreshRateLimits(Date.now())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return deepClone(this.snapshotCache);
|
||||
}
|
||||
|
||||
private hasPendingMutation(): boolean {
|
||||
return this.activeMutationCount > 0 || this.mutationQueueRelease !== null;
|
||||
}
|
||||
|
||||
private hasFreshRateLimits(now: number): boolean {
|
||||
return (
|
||||
this.lastKnownRateLimits !== null &&
|
||||
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||
if (!this.snapshotCache) {
|
||||
return this.refreshSnapshot();
|
||||
|
|
|
|||
|
|
@ -12,26 +12,39 @@ const ACCOUNT_RATE_LIMITS_TIMEOUT_MS = 4_500;
|
|||
const ACCOUNT_LOGOUT_TIMEOUT_MS = 3_500;
|
||||
const INITIALIZE_TIMEOUT_MS = 6_000;
|
||||
const TOTAL_TIMEOUT_MS = 9_000;
|
||||
const TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS = 15_000;
|
||||
|
||||
type CodexAccountRateLimitsReadResult =
|
||||
| { ok: true; payload: CodexAppServerGetAccountRateLimitsResponse }
|
||||
| { ok: false; error: unknown };
|
||||
|
||||
export class CodexAccountAppServerClient {
|
||||
constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {}
|
||||
|
||||
async readAccount(options: {
|
||||
async readAccountSnapshot(options: {
|
||||
binaryPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
refreshToken?: boolean;
|
||||
includeRateLimits?: boolean;
|
||||
}): Promise<{
|
||||
account: CodexAppServerGetAccountResponse;
|
||||
rateLimits: CodexAccountRateLimitsReadResult | null;
|
||||
initialize: { codexHome: string; platformFamily: string; platformOs: string };
|
||||
}> {
|
||||
const includeRateLimits = options.includeRateLimits === true;
|
||||
|
||||
return this.sessionFactory.withSession(
|
||||
{
|
||||
binaryPath: options.binaryPath,
|
||||
env: options.env,
|
||||
requestTimeoutMs: ACCOUNT_READ_TIMEOUT_MS,
|
||||
requestTimeoutMs: includeRateLimits
|
||||
? ACCOUNT_RATE_LIMITS_TIMEOUT_MS
|
||||
: ACCOUNT_READ_TIMEOUT_MS,
|
||||
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
|
||||
totalTimeoutMs: TOTAL_TIMEOUT_MS,
|
||||
label: 'codex app-server account/read',
|
||||
totalTimeoutMs: includeRateLimits ? TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS : TOTAL_TIMEOUT_MS,
|
||||
label: includeRateLimits
|
||||
? 'codex app-server account/read with rateLimits/read'
|
||||
: 'codex app-server account/read',
|
||||
},
|
||||
async (session) => {
|
||||
const account = await session.request<CodexAppServerGetAccountResponse>(
|
||||
|
|
@ -42,8 +55,25 @@ export class CodexAccountAppServerClient {
|
|||
ACCOUNT_READ_TIMEOUT_MS
|
||||
);
|
||||
|
||||
let rateLimits: CodexAccountRateLimitsReadResult | null = null;
|
||||
if (includeRateLimits) {
|
||||
try {
|
||||
rateLimits = {
|
||||
ok: true,
|
||||
payload: await session.request<CodexAppServerGetAccountRateLimitsResponse>(
|
||||
'account/rateLimits/read',
|
||||
undefined,
|
||||
ACCOUNT_RATE_LIMITS_TIMEOUT_MS
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
rateLimits = { ok: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
rateLimits,
|
||||
initialize: {
|
||||
codexHome: session.initializeResponse.codexHome,
|
||||
platformFamily: session.initializeResponse.platformFamily,
|
||||
|
|
@ -54,6 +84,21 @@ export class CodexAccountAppServerClient {
|
|||
);
|
||||
}
|
||||
|
||||
async readAccount(options: {
|
||||
binaryPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
refreshToken?: boolean;
|
||||
}): Promise<{
|
||||
account: CodexAppServerGetAccountResponse;
|
||||
initialize: { codexHome: string; platformFamily: string; platformOs: string };
|
||||
}> {
|
||||
const result = await this.readAccountSnapshot(options);
|
||||
return {
|
||||
account: result.account,
|
||||
initialize: result.initialize,
|
||||
};
|
||||
}
|
||||
|
||||
async readRateLimits(options: {
|
||||
binaryPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CodexAccountAppServerClient } from '../CodexAccountAppServerClient';
|
||||
|
||||
import type {
|
||||
CodexAppServerSession,
|
||||
CodexAppServerSessionFactory,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
|
||||
function createFactory(request: CodexAppServerSession['request']): {
|
||||
factory: CodexAppServerSessionFactory;
|
||||
requests: { method: string; params: unknown }[];
|
||||
} {
|
||||
const requests: { method: string; params: unknown }[] = [];
|
||||
const session: CodexAppServerSession = {
|
||||
initializeResponse: {
|
||||
userAgent: 'codex-cli 0.117.0',
|
||||
codexHome: '/Users/me/.codex',
|
||||
platformFamily: 'macos',
|
||||
platformOs: 'darwin',
|
||||
},
|
||||
request: async <TResult>(method: string, params?: unknown, timeoutMs?: number) => {
|
||||
requests.push({ method, params });
|
||||
return request<TResult>(method, params, timeoutMs);
|
||||
},
|
||||
notify: async () => undefined,
|
||||
onNotification: () => () => undefined,
|
||||
close: async () => undefined,
|
||||
};
|
||||
|
||||
const factory = {
|
||||
withSession: async <TResult>(
|
||||
_options: unknown,
|
||||
handler: (session: CodexAppServerSession) => Promise<TResult>
|
||||
): Promise<TResult> => handler(session),
|
||||
} as unknown as CodexAppServerSessionFactory;
|
||||
|
||||
return { factory, requests };
|
||||
}
|
||||
|
||||
describe('CodexAccountAppServerClient', () => {
|
||||
it('reads account and optional rate limits in one app-server session', async () => {
|
||||
let sessionCount = 0;
|
||||
const { factory, requests } = createFactory(async <TResult>(method: string) => {
|
||||
if (method === 'account/read') {
|
||||
return {
|
||||
account: { type: 'chatgpt', email: 'user@example.com', planType: 'pro' },
|
||||
requiresOpenaiAuth: true,
|
||||
} as TResult;
|
||||
}
|
||||
if (method === 'account/rateLimits/read') {
|
||||
return {
|
||||
rateLimits: {
|
||||
limitId: 'codex',
|
||||
limitName: null,
|
||||
primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null },
|
||||
secondary: null,
|
||||
credits: null,
|
||||
planType: 'pro',
|
||||
},
|
||||
rateLimitsByLimitId: null,
|
||||
} as TResult;
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
});
|
||||
const countedFactory = {
|
||||
withSession: async <TResult>(
|
||||
options: unknown,
|
||||
handler: (session: CodexAppServerSession) => Promise<TResult>
|
||||
): Promise<TResult> => {
|
||||
sessionCount += 1;
|
||||
return factory.withSession(options as never, handler);
|
||||
},
|
||||
} as unknown as CodexAppServerSessionFactory;
|
||||
|
||||
const client = new CodexAccountAppServerClient(countedFactory);
|
||||
const result = await client.readAccountSnapshot({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
env: {},
|
||||
refreshToken: true,
|
||||
includeRateLimits: true,
|
||||
});
|
||||
|
||||
expect(sessionCount).toBe(1);
|
||||
expect(result.account.account).toMatchObject({
|
||||
type: 'chatgpt',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
expect(result.rateLimits).toMatchObject({
|
||||
ok: true,
|
||||
payload: {
|
||||
rateLimits: {
|
||||
primary: { usedPercent: 42 },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.initialize.codexHome).toBe('/Users/me/.codex');
|
||||
expect(requests).toEqual([
|
||||
{ method: 'account/read', params: { refreshToken: true } },
|
||||
{ method: 'account/rateLimits/read', params: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a successful account read when optional rate limits fail', async () => {
|
||||
const rateLimitError = new Error('rate limits failed');
|
||||
const { factory } = createFactory(async <TResult>(method: string) => {
|
||||
if (method === 'account/read') {
|
||||
return {
|
||||
account: { type: 'apiKey' },
|
||||
requiresOpenaiAuth: false,
|
||||
} as TResult;
|
||||
}
|
||||
if (method === 'account/rateLimits/read') {
|
||||
throw rateLimitError;
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
});
|
||||
|
||||
const client = new CodexAccountAppServerClient(factory);
|
||||
const result = await client.readAccountSnapshot({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
env: {},
|
||||
includeRateLimits: true,
|
||||
});
|
||||
|
||||
expect(result.account).toEqual({
|
||||
account: { type: 'apiKey' },
|
||||
requiresOpenaiAuth: false,
|
||||
});
|
||||
expect(result.rateLimits).toEqual({
|
||||
ok: false,
|
||||
error: rateLimitError,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces account read failures without attempting rate limits', async () => {
|
||||
const requests: string[] = [];
|
||||
const { factory } = createFactory(async <TResult>(method: string) => {
|
||||
requests.push(method);
|
||||
if (method === 'account/read') {
|
||||
throw new Error('account failed');
|
||||
}
|
||||
return {} as TResult;
|
||||
});
|
||||
|
||||
const client = new CodexAccountAppServerClient(factory);
|
||||
|
||||
await expect(
|
||||
client.readAccountSnapshot({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
env: {},
|
||||
includeRateLimits: true,
|
||||
})
|
||||
).rejects.toThrow('account failed');
|
||||
expect(requests).toEqual(['account/read']);
|
||||
});
|
||||
});
|
||||
2
src/features/member-work-sync/contracts/index.ts
Normal file
2
src/features/member-work-sync/contracts/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './ipc';
|
||||
export type * from './types';
|
||||
3
src/features/member-work-sync/contracts/ipc.ts
Normal file
3
src/features/member-work-sync/contracts/ipc.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus';
|
||||
export const MEMBER_WORK_SYNC_GET_METRICS = 'member-work-sync:getMetrics';
|
||||
export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report';
|
||||
306
src/features/member-work-sync/contracts/types.ts
Normal file
306
src/features/member-work-sync/contracts/types.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
export type MemberWorkSyncReportState = 'still_working' | 'blocked' | 'caught_up';
|
||||
|
||||
export type MemberWorkSyncStatusState =
|
||||
| 'caught_up'
|
||||
| 'needs_sync'
|
||||
| 'still_working'
|
||||
| 'blocked'
|
||||
| 'inactive'
|
||||
| 'unknown';
|
||||
|
||||
export type MemberWorkSyncActionableWorkKind =
|
||||
| 'work'
|
||||
| 'review'
|
||||
| 'clarification'
|
||||
| 'blocked_dependency';
|
||||
|
||||
export type MemberWorkSyncActionableWorkPriority =
|
||||
| 'normal'
|
||||
| 'review_requested'
|
||||
| 'blocked'
|
||||
| 'needs_clarification';
|
||||
|
||||
export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
||||
|
||||
export interface MemberWorkSyncActionableWorkItem {
|
||||
taskId: string;
|
||||
displayId?: string;
|
||||
subject: string;
|
||||
kind: MemberWorkSyncActionableWorkKind;
|
||||
assignee: string;
|
||||
priority: MemberWorkSyncActionableWorkPriority;
|
||||
reason: string;
|
||||
evidence: {
|
||||
status: string;
|
||||
owner?: string;
|
||||
reviewer?: string;
|
||||
reviewState?: string;
|
||||
needsClarification?: 'lead' | 'user';
|
||||
blockerTaskIds?: string[];
|
||||
blockedByTaskIds?: string[];
|
||||
historyEventIds?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncAgenda {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
generatedAt: string;
|
||||
fingerprint: string;
|
||||
items: MemberWorkSyncActionableWorkItem[];
|
||||
diagnostics: string[];
|
||||
sourceRevision?: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncReport {
|
||||
state: MemberWorkSyncReportState;
|
||||
agendaFingerprint: string;
|
||||
memberName: string;
|
||||
teamName: string;
|
||||
reportedAt: string;
|
||||
expiresAt?: string;
|
||||
taskIds?: string[];
|
||||
note?: string;
|
||||
source?: 'mcp' | 'app' | 'test';
|
||||
accepted: boolean;
|
||||
rejectionCode?: string;
|
||||
}
|
||||
|
||||
export type MemberWorkSyncReportIntentStatus = 'pending' | 'accepted' | 'rejected' | 'superseded';
|
||||
|
||||
export interface MemberWorkSyncReportIntent {
|
||||
id: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
request: MemberWorkSyncReportRequest;
|
||||
reason: string;
|
||||
status: MemberWorkSyncReportIntentStatus;
|
||||
recordedAt: string;
|
||||
processedAt?: string;
|
||||
resultCode?: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncShadowDiagnostics {
|
||||
reconciledBy: 'request' | 'queue' | 'report';
|
||||
wouldNudge: boolean;
|
||||
fingerprintChanged: boolean;
|
||||
previousFingerprint?: string;
|
||||
triggerReasons?: string[];
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncStatus {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
state: MemberWorkSyncStatusState;
|
||||
agenda: MemberWorkSyncAgenda;
|
||||
report?: MemberWorkSyncReport;
|
||||
reportToken?: string;
|
||||
reportTokenExpiresAt?: string;
|
||||
shadow?: MemberWorkSyncShadowDiagnostics;
|
||||
evaluatedAt: string;
|
||||
diagnostics: string[];
|
||||
providerId?: MemberWorkSyncProviderId;
|
||||
}
|
||||
|
||||
export type MemberWorkSyncMetricEventKind =
|
||||
| 'status_evaluated'
|
||||
| 'would_nudge'
|
||||
| 'fingerprint_changed'
|
||||
| 'report_accepted'
|
||||
| 'report_rejected';
|
||||
|
||||
export interface MemberWorkSyncMetricEvent {
|
||||
id: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
kind: MemberWorkSyncMetricEventKind;
|
||||
state: MemberWorkSyncStatusState;
|
||||
agendaFingerprint: string;
|
||||
recordedAt: string;
|
||||
actionableCount: number;
|
||||
providerId?: MemberWorkSyncProviderId;
|
||||
previousFingerprint?: string;
|
||||
triggerReasons?: string[];
|
||||
reportState?: MemberWorkSyncReportState;
|
||||
rejectionCode?: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncTeamMetrics {
|
||||
teamName: string;
|
||||
generatedAt: string;
|
||||
memberCount: number;
|
||||
stateCounts: Record<MemberWorkSyncStatusState, number>;
|
||||
actionableItemCount: number;
|
||||
wouldNudgeCount: number;
|
||||
fingerprintChangeCount: number;
|
||||
reportAcceptedCount: number;
|
||||
reportRejectedCount: number;
|
||||
recentEvents: MemberWorkSyncMetricEvent[];
|
||||
phase2Readiness: MemberWorkSyncPhase2ReadinessAssessment;
|
||||
}
|
||||
|
||||
export type MemberWorkSyncPhase2ReadinessState =
|
||||
| 'collecting_shadow_data'
|
||||
| 'shadow_ready'
|
||||
| 'blocked';
|
||||
|
||||
export type MemberWorkSyncPhase2ReadinessReason =
|
||||
| 'insufficient_members'
|
||||
| 'insufficient_status_events'
|
||||
| 'insufficient_observation_window'
|
||||
| 'would_nudge_rate_high'
|
||||
| 'fingerprint_churn_high'
|
||||
| 'report_rejection_rate_high';
|
||||
|
||||
export interface MemberWorkSyncPhase2ReadinessThresholds {
|
||||
minObservedMembers: number;
|
||||
minStatusEvents: number;
|
||||
minObservationHours: number;
|
||||
maxWouldNudgesPerMemberHour: number;
|
||||
maxFingerprintChangesPerMemberHour: number;
|
||||
maxReportRejectionRate: number;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncPhase2ReadinessRates {
|
||||
observationHours: number;
|
||||
statusEventCount: number;
|
||||
wouldNudgesPerMemberHour: number;
|
||||
fingerprintChangesPerMemberHour: number;
|
||||
reportRejectionRate: number;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncPhase2ReadinessAssessment {
|
||||
state: MemberWorkSyncPhase2ReadinessState;
|
||||
reasons: MemberWorkSyncPhase2ReadinessReason[];
|
||||
thresholds: MemberWorkSyncPhase2ReadinessThresholds;
|
||||
rates: MemberWorkSyncPhase2ReadinessRates;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncReportRequest {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
state: MemberWorkSyncReportState;
|
||||
agendaFingerprint: string;
|
||||
reportToken?: string;
|
||||
taskIds?: string[];
|
||||
note?: string;
|
||||
reportedAt?: string;
|
||||
leaseTtlMs?: number;
|
||||
source?: 'mcp' | 'app' | 'test';
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncReportResult {
|
||||
accepted: boolean;
|
||||
code: string;
|
||||
message: string;
|
||||
status: MemberWorkSyncStatus;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncStatusRequest {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncMetricsRequest {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export type MemberWorkSyncOutboxStatus =
|
||||
| 'pending'
|
||||
| 'claimed'
|
||||
| 'delivered'
|
||||
| 'superseded'
|
||||
| 'failed_retryable'
|
||||
| 'failed_terminal';
|
||||
|
||||
export interface MemberWorkSyncNudgePayload {
|
||||
from: 'system';
|
||||
to: string;
|
||||
messageKind: 'member_work_sync_nudge';
|
||||
source: 'member-work-sync';
|
||||
actionMode: 'do';
|
||||
text: string;
|
||||
taskRefs: {
|
||||
taskId: string;
|
||||
displayId: string;
|
||||
teamName: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncOutboxItem {
|
||||
id: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
agendaFingerprint: string;
|
||||
payloadHash: string;
|
||||
payload: MemberWorkSyncNudgePayload;
|
||||
status: MemberWorkSyncOutboxStatus;
|
||||
attemptGeneration: number;
|
||||
claimedBy?: string;
|
||||
claimedAt?: string;
|
||||
deliveredMessageId?: string;
|
||||
lastError?: string;
|
||||
nextAttemptAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type MemberWorkSyncOutboxEnsureResult =
|
||||
| { ok: true; outcome: 'created' | 'existing'; item: MemberWorkSyncOutboxItem }
|
||||
| {
|
||||
ok: false;
|
||||
outcome: 'payload_conflict';
|
||||
item: MemberWorkSyncOutboxItem;
|
||||
existingPayloadHash: string;
|
||||
requestedPayloadHash: string;
|
||||
};
|
||||
|
||||
export interface MemberWorkSyncOutboxEnsureInput {
|
||||
id: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
agendaFingerprint: string;
|
||||
payloadHash: string;
|
||||
payload: MemberWorkSyncNudgePayload;
|
||||
nowIso: string;
|
||||
nextAttemptAt?: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncOutboxClaimInput {
|
||||
teamName: string;
|
||||
claimedBy: string;
|
||||
nowIso: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncOutboxMarkDeliveredInput {
|
||||
teamName: string;
|
||||
id: string;
|
||||
attemptGeneration: number;
|
||||
deliveredMessageId: string;
|
||||
nowIso: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncOutboxMarkSupersededInput {
|
||||
teamName: string;
|
||||
id: string;
|
||||
reason: string;
|
||||
nowIso: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncOutboxMarkFailedInput {
|
||||
teamName: string;
|
||||
id: string;
|
||||
attemptGeneration: number;
|
||||
error: string;
|
||||
retryable: boolean;
|
||||
nowIso: string;
|
||||
nextAttemptAt?: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncOutboxCountRecentDeliveredInput {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
sinceIso: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type {
|
||||
MemberWorkSyncAuditEvent,
|
||||
MemberWorkSyncAuditEventName,
|
||||
MemberWorkSyncUseCaseDeps,
|
||||
} from './ports';
|
||||
|
||||
export type MemberWorkSyncAuditEventInput = Omit<MemberWorkSyncAuditEvent, 'timestamp'> & {
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export async function appendMemberWorkSyncAudit(
|
||||
deps: Pick<MemberWorkSyncUseCaseDeps, 'auditJournal' | 'clock' | 'logger'>,
|
||||
input: MemberWorkSyncAuditEventInput
|
||||
): Promise<void> {
|
||||
if (!deps.auditJournal) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deps.auditJournal.append({
|
||||
...input,
|
||||
timestamp: input.timestamp ?? deps.clock.now().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
deps.logger?.warn('member work sync audit event failed', {
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
event: input.event,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function reasonToAuditEvent(reason: string): MemberWorkSyncAuditEventName {
|
||||
if (reason.startsWith('member_busy:')) {
|
||||
return 'member_busy';
|
||||
}
|
||||
if (reason === 'watchdog_cooldown_active') {
|
||||
return 'watchdog_cooldown_active';
|
||||
}
|
||||
if (reason === 'team_inactive') {
|
||||
return 'team_inactive';
|
||||
}
|
||||
return 'nudge_skipped';
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler';
|
||||
|
||||
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
export class MemberWorkSyncDiagnosticsReader {
|
||||
private readonly reconciler: MemberWorkSyncReconciler;
|
||||
|
||||
constructor(deps: MemberWorkSyncUseCaseDeps) {
|
||||
this.reconciler = new MemberWorkSyncReconciler(deps);
|
||||
}
|
||||
|
||||
async execute(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> {
|
||||
return this.reconciler.execute(request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { assessMemberWorkSyncPhase2Readiness } from '../domain';
|
||||
|
||||
import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics {
|
||||
return {
|
||||
teamName,
|
||||
generatedAt,
|
||||
memberCount: 0,
|
||||
stateCounts: {
|
||||
caught_up: 0,
|
||||
needs_sync: 0,
|
||||
still_working: 0,
|
||||
blocked: 0,
|
||||
inactive: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
actionableItemCount: 0,
|
||||
wouldNudgeCount: 0,
|
||||
fingerprintChangeCount: 0,
|
||||
reportAcceptedCount: 0,
|
||||
reportRejectedCount: 0,
|
||||
recentEvents: [],
|
||||
phase2Readiness: assessMemberWorkSyncPhase2Readiness({
|
||||
memberCount: 0,
|
||||
recentEvents: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export class MemberWorkSyncMetricsReader {
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||
|
||||
async execute(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics> {
|
||||
if (!this.deps.statusStore.readTeamMetrics) {
|
||||
return emptyMetrics(request.teamName, this.deps.clock.now().toISOString());
|
||||
}
|
||||
const metrics = await this.deps.statusStore.readTeamMetrics(request.teamName);
|
||||
return {
|
||||
...metrics,
|
||||
phase2Readiness:
|
||||
metrics.phase2Readiness ??
|
||||
assessMemberWorkSyncPhase2Readiness({
|
||||
memberCount: metrics.memberCount,
|
||||
recentEvents: metrics.recentEvents,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
|
||||
|
||||
import type { MemberWorkSyncOutboxItem } from '../../contracts';
|
||||
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||
const MEMBER_WORK_SYNC_RETRY_BASE_MINUTES = 10;
|
||||
const MEMBER_WORK_SYNC_RETRY_MAX_MINUTES = 60;
|
||||
|
||||
export interface MemberWorkSyncNudgeDispatchSummary {
|
||||
claimed: number;
|
||||
delivered: number;
|
||||
superseded: number;
|
||||
retryable: number;
|
||||
terminal: number;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncNudgeDispatchOptions {
|
||||
claimedBy: string;
|
||||
teamNames: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function emptySummary(): MemberWorkSyncNudgeDispatchSummary {
|
||||
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
|
||||
}
|
||||
|
||||
function addMinutes(iso: string, minutes: number): string {
|
||||
return new Date(Date.parse(iso) + minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
function subtractMinutes(iso: string, minutes: number): string {
|
||||
return new Date(Date.parse(iso) - minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
function stableJitterMinutes(id: string, attemptGeneration: number): number {
|
||||
const seed = `${id}:${attemptGeneration}`;
|
||||
let value = 0;
|
||||
for (const char of seed) {
|
||||
value = (value * 31 + char.charCodeAt(0)) % 997;
|
||||
}
|
||||
return value % 5;
|
||||
}
|
||||
|
||||
function nextRetryAt(item: MemberWorkSyncOutboxItem, nowIso: string): string {
|
||||
const exponentialMinutes =
|
||||
MEMBER_WORK_SYNC_RETRY_BASE_MINUTES * 2 ** Math.max(0, item.attemptGeneration - 1);
|
||||
const cappedMinutes = Math.min(MEMBER_WORK_SYNC_RETRY_MAX_MINUTES, exponentialMinutes);
|
||||
return addMinutes(nowIso, cappedMinutes + stableJitterMinutes(item.id, item.attemptGeneration));
|
||||
}
|
||||
|
||||
export class MemberWorkSyncNudgeDispatcher {
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||
|
||||
async dispatchDue(
|
||||
options: MemberWorkSyncNudgeDispatchOptions
|
||||
): Promise<MemberWorkSyncNudgeDispatchSummary> {
|
||||
const outbox = this.deps.outboxStore;
|
||||
const inbox = this.deps.inboxNudge;
|
||||
if (!outbox || !inbox) {
|
||||
return emptySummary();
|
||||
}
|
||||
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
const summary = emptySummary();
|
||||
for (const teamName of [
|
||||
...new Set(options.teamNames.map((name) => name.trim()).filter(Boolean)),
|
||||
]) {
|
||||
const claimed = await outbox.claimDue({
|
||||
teamName,
|
||||
claimedBy: options.claimedBy,
|
||||
nowIso,
|
||||
limit: options.limit ?? 10,
|
||||
});
|
||||
summary.claimed += claimed.length;
|
||||
for (const item of claimed) {
|
||||
const result = await this.dispatchItem(item, nowIso);
|
||||
summary[result] += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async dispatchItem(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string
|
||||
): Promise<keyof Omit<MemberWorkSyncNudgeDispatchSummary, 'claimed'>> {
|
||||
const outbox = this.deps.outboxStore;
|
||||
const inbox = this.deps.inboxNudge;
|
||||
if (!outbox || !inbox) {
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
const revalidation = await this.revalidate(item, nowIso);
|
||||
if (!revalidation.ok) {
|
||||
if (revalidation.retryable) {
|
||||
await outbox.markFailed({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
attemptGeneration: item.attemptGeneration,
|
||||
error: revalidation.reason,
|
||||
retryable: true,
|
||||
nowIso,
|
||||
nextAttemptAt: revalidation.nextAttemptAt ?? nextRetryAt(item, nowIso),
|
||||
});
|
||||
await this.appendDispatchAudit(
|
||||
item,
|
||||
reasonToAuditEvent(revalidation.reason),
|
||||
revalidation.reason
|
||||
);
|
||||
return 'retryable';
|
||||
}
|
||||
await outbox.markSuperseded({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
reason: revalidation.reason,
|
||||
nowIso,
|
||||
});
|
||||
await this.appendDispatchAudit(item, 'nudge_superseded', revalidation.reason);
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
try {
|
||||
const inserted = await inbox.insertIfAbsent({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
messageId: item.id,
|
||||
payloadHash: item.payloadHash,
|
||||
payload: item.payload,
|
||||
timestamp: nowIso,
|
||||
});
|
||||
if (inserted.conflict) {
|
||||
await outbox.markFailed({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
attemptGeneration: item.attemptGeneration,
|
||||
error: 'inbox_payload_conflict',
|
||||
retryable: false,
|
||||
nowIso,
|
||||
});
|
||||
await this.appendDispatchAudit(item, 'nudge_skipped', 'inbox_payload_conflict');
|
||||
return 'terminal';
|
||||
}
|
||||
await outbox.markDelivered({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
attemptGeneration: item.attemptGeneration,
|
||||
deliveredMessageId: inserted.messageId,
|
||||
nowIso,
|
||||
});
|
||||
await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted');
|
||||
return 'delivered';
|
||||
} catch (error) {
|
||||
await outbox.markFailed({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
attemptGeneration: item.attemptGeneration,
|
||||
error: String(error),
|
||||
retryable: true,
|
||||
nowIso,
|
||||
nextAttemptAt: nextRetryAt(item, nowIso),
|
||||
});
|
||||
await this.appendDispatchAudit(item, 'nudge_retryable', String(error));
|
||||
return 'retryable';
|
||||
}
|
||||
}
|
||||
|
||||
private async appendDispatchAudit(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
event: MemberWorkSyncAuditEventName,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
event,
|
||||
source: 'nudge_dispatcher',
|
||||
agendaFingerprint: item.agendaFingerprint,
|
||||
reason,
|
||||
taskRefs: item.payload.taskRefs,
|
||||
messagePreview: item.payload.text,
|
||||
});
|
||||
}
|
||||
|
||||
private async revalidate(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string
|
||||
): Promise<
|
||||
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
|
||||
> {
|
||||
if (this.deps.lifecycle && !(await this.deps.lifecycle.isTeamActive(item.teamName))) {
|
||||
return { ok: false, reason: 'team_inactive', retryable: false };
|
||||
}
|
||||
|
||||
const status = await this.deps.statusStore.read({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
});
|
||||
if (!status) {
|
||||
return { ok: false, reason: 'status_missing', retryable: false };
|
||||
}
|
||||
if (
|
||||
status.state !== 'needs_sync' ||
|
||||
status.shadow?.wouldNudge !== true ||
|
||||
status.agenda.fingerprint !== item.agendaFingerprint
|
||||
) {
|
||||
return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false };
|
||||
}
|
||||
|
||||
if (!this.deps.statusStore.readTeamMetrics) {
|
||||
return { ok: false, reason: 'metrics_unavailable', retryable: true };
|
||||
}
|
||||
const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName);
|
||||
if (metrics.phase2Readiness.state !== 'shadow_ready') {
|
||||
return { ok: false, reason: 'phase2_not_ready', retryable: true };
|
||||
}
|
||||
|
||||
const recentDelivered = await this.deps.outboxStore?.countRecentDelivered({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
sinceIso: subtractMinutes(nowIso, 60),
|
||||
});
|
||||
if (
|
||||
recentDelivered != null &&
|
||||
recentDelivered >= MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'member_nudge_rate_limited',
|
||||
retryable: true,
|
||||
nextAttemptAt: addMinutes(nowIso, 60),
|
||||
};
|
||||
}
|
||||
|
||||
const busy = await this.deps.busySignal?.isBusy({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
nowIso,
|
||||
});
|
||||
if (busy?.busy) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `member_busy:${busy.reason ?? 'unknown'}`,
|
||||
retryable: true,
|
||||
nextAttemptAt: busy.retryAfterIso,
|
||||
};
|
||||
}
|
||||
|
||||
const taskIds = item.payload.taskRefs.map((taskRef) => taskRef.taskId);
|
||||
if (
|
||||
this.deps.watchdogCooldown &&
|
||||
(await this.deps.watchdogCooldown.hasRecentNudge({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
taskIds,
|
||||
nowIso,
|
||||
}))
|
||||
) {
|
||||
return { ok: false, reason: 'watchdog_cooldown_active', retryable: true };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
|
||||
|
||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
export interface MemberWorkSyncNudgeOutboxPlanResult {
|
||||
planned: boolean;
|
||||
code:
|
||||
| 'outbox_unavailable'
|
||||
| 'metrics_unavailable'
|
||||
| 'status_not_nudgeable'
|
||||
| 'phase2_not_ready'
|
||||
| 'created'
|
||||
| 'existing'
|
||||
| 'payload_conflict';
|
||||
}
|
||||
|
||||
export class MemberWorkSyncNudgeOutboxPlanner {
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||
|
||||
async plan(status: MemberWorkSyncStatus): Promise<MemberWorkSyncNudgeOutboxPlanResult> {
|
||||
if (!this.deps.outboxStore) {
|
||||
return { planned: false, code: 'outbox_unavailable' };
|
||||
}
|
||||
if (!this.deps.statusStore.readTeamMetrics) {
|
||||
return { planned: false, code: 'metrics_unavailable' };
|
||||
}
|
||||
|
||||
const input = buildMemberWorkSyncOutboxEnsureInput({
|
||||
status,
|
||||
hash: this.deps.hash,
|
||||
nowIso: status.evaluatedAt,
|
||||
});
|
||||
if (!input) {
|
||||
return { planned: false, code: 'status_not_nudgeable' };
|
||||
}
|
||||
|
||||
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
|
||||
if (metrics.phase2Readiness.state !== 'shadow_ready') {
|
||||
await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' });
|
||||
return { planned: false, code: 'phase2_not_ready' };
|
||||
}
|
||||
|
||||
const result = await this.deps.outboxStore.ensurePending(input);
|
||||
if (!result.ok) {
|
||||
this.deps.logger?.warn('member work sync nudge outbox payload conflict', {
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
outboxId: input.id,
|
||||
existingPayloadHash: result.existingPayloadHash,
|
||||
requestedPayloadHash: result.requestedPayloadHash,
|
||||
});
|
||||
await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' });
|
||||
return { planned: false, code: 'payload_conflict' };
|
||||
}
|
||||
|
||||
const planResult = { planned: true, code: result.outcome } as const;
|
||||
await this.appendPlanAudit(status, planResult);
|
||||
return planResult;
|
||||
}
|
||||
|
||||
private async appendPlanAudit(
|
||||
status: MemberWorkSyncStatus,
|
||||
result: MemberWorkSyncNudgeOutboxPlanResult
|
||||
): Promise<void> {
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
event: result.planned ? 'nudge_planned' : 'nudge_skipped',
|
||||
source: 'nudge_planner',
|
||||
agendaFingerprint: status.agenda.fingerprint,
|
||||
state: status.state,
|
||||
actionableCount: status.agenda.items.length,
|
||||
reason: result.code,
|
||||
...(status.providerId ? { providerId: status.providerId } : {}),
|
||||
taskRefs: status.agenda.items.map((item) => ({
|
||||
taskId: item.taskId,
|
||||
displayId: item.displayId,
|
||||
teamName: status.teamName,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { MemberWorkSyncReporter } from './MemberWorkSyncReporter';
|
||||
|
||||
import type { MemberWorkSyncReportIntentStatus } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
export interface MemberWorkSyncPendingReportReplaySummary {
|
||||
processed: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
superseded: number;
|
||||
}
|
||||
|
||||
function statusForResult(input: {
|
||||
accepted: boolean;
|
||||
code: string;
|
||||
}): MemberWorkSyncReportIntentStatus {
|
||||
if (input.accepted) {
|
||||
return 'accepted';
|
||||
}
|
||||
if (input.code === 'member_inactive' || input.code === 'team_runtime_inactive') {
|
||||
return 'superseded';
|
||||
}
|
||||
return 'rejected';
|
||||
}
|
||||
|
||||
export class MemberWorkSyncPendingReportIntentReplayer {
|
||||
private readonly reporter: MemberWorkSyncReporter;
|
||||
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {
|
||||
this.reporter = new MemberWorkSyncReporter(deps);
|
||||
}
|
||||
|
||||
async replayTeam(teamName: string): Promise<MemberWorkSyncPendingReportReplaySummary> {
|
||||
const store = this.deps.reportStore;
|
||||
if (!store?.listPendingReports || !store.markPendingReportProcessed) {
|
||||
return { processed: 0, accepted: 0, rejected: 0, superseded: 0 };
|
||||
}
|
||||
|
||||
const intents = await store.listPendingReports(teamName);
|
||||
const summary: MemberWorkSyncPendingReportReplaySummary = {
|
||||
processed: 0,
|
||||
accepted: 0,
|
||||
rejected: 0,
|
||||
superseded: 0,
|
||||
};
|
||||
|
||||
for (const intent of intents) {
|
||||
let status: MemberWorkSyncReportIntentStatus = 'rejected';
|
||||
let resultCode = 'replay_failed';
|
||||
try {
|
||||
const result = await this.reporter.execute({
|
||||
...intent.request,
|
||||
source: intent.request.source ?? 'mcp',
|
||||
});
|
||||
status = statusForResult(result);
|
||||
resultCode = result.code;
|
||||
} catch (error) {
|
||||
this.deps.logger?.warn('member work sync pending report replay failed', {
|
||||
teamName,
|
||||
intentId: intent.id,
|
||||
error: String(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
summary.processed += 1;
|
||||
if (status === 'accepted') {
|
||||
summary.accepted += 1;
|
||||
} else if (status === 'superseded') {
|
||||
summary.superseded += 1;
|
||||
} else {
|
||||
summary.rejected += 1;
|
||||
}
|
||||
await store.markPendingReportProcessed(teamName, intent.id, {
|
||||
status,
|
||||
resultCode,
|
||||
processedAt: this.deps.clock.now().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
buildAgendaFingerprintPayload,
|
||||
canonicalizeAgendaFingerprintPayload,
|
||||
decideMemberWorkSyncStatus,
|
||||
formatAgendaFingerprint,
|
||||
} from '../domain';
|
||||
|
||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||
import { MemberWorkSyncNudgeOutboxPlanner } from './MemberWorkSyncNudgeOutboxPlanner';
|
||||
|
||||
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
|
||||
import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
export interface MemberWorkSyncReconcileContext {
|
||||
reconciledBy?: 'request' | 'queue';
|
||||
triggerReasons?: string[];
|
||||
}
|
||||
|
||||
export function finalizeMemberWorkSyncAgenda(
|
||||
deps: MemberWorkSyncUseCaseDeps,
|
||||
source: MemberWorkSyncAgendaSourceResult
|
||||
) {
|
||||
const payload = buildAgendaFingerprintPayload({
|
||||
teamName: source.agenda.teamName,
|
||||
memberName: source.agenda.memberName,
|
||||
items: source.agenda.items,
|
||||
sourceRevision: source.agenda.sourceRevision,
|
||||
});
|
||||
const fingerprint = formatAgendaFingerprint(
|
||||
deps.hash.sha256Hex(canonicalizeAgendaFingerprintPayload(payload))
|
||||
);
|
||||
return {
|
||||
...source.agenda,
|
||||
fingerprint,
|
||||
diagnostics: [...source.agenda.diagnostics, ...source.diagnostics],
|
||||
};
|
||||
}
|
||||
|
||||
export class MemberWorkSyncReconciler {
|
||||
private readonly nudgeOutboxPlanner: MemberWorkSyncNudgeOutboxPlanner;
|
||||
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {
|
||||
this.nudgeOutboxPlanner = new MemberWorkSyncNudgeOutboxPlanner(deps);
|
||||
}
|
||||
|
||||
async execute(
|
||||
request: MemberWorkSyncStatusRequest,
|
||||
context: MemberWorkSyncReconcileContext = {}
|
||||
): Promise<MemberWorkSyncStatus> {
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: request.teamName,
|
||||
memberName: request.memberName,
|
||||
event: 'reconcile_started',
|
||||
source: 'reconciler',
|
||||
...(context.triggerReasons?.length ? { triggerReasons: context.triggerReasons } : {}),
|
||||
});
|
||||
const source = await this.deps.agendaSource.loadAgenda(request);
|
||||
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
event: 'agenda_loaded',
|
||||
source: 'reconciler',
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
actionableCount: agenda.items.length,
|
||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
diagnostics: agenda.diagnostics,
|
||||
});
|
||||
const previous = await this.deps.statusStore.read(request);
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(agenda.teamName)
|
||||
: true;
|
||||
const decision = decideMemberWorkSyncStatus({
|
||||
agenda,
|
||||
latestAcceptedReport: previous?.report?.accepted ? previous.report : null,
|
||||
nowIso,
|
||||
inactive: source.inactive || !teamActive,
|
||||
});
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
event: source.inactive || !teamActive ? 'team_inactive' : 'decision_made',
|
||||
source: 'reconciler',
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
state: decision.state,
|
||||
actionableCount: agenda.items.length,
|
||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
diagnostics: decision.diagnostics,
|
||||
});
|
||||
|
||||
const status = await attachMemberWorkSyncReportToken(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
state: decision.state,
|
||||
agenda,
|
||||
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
|
||||
shadow: {
|
||||
reconciledBy: context.reconciledBy ?? 'request',
|
||||
wouldNudge: decision.state === 'needs_sync' && agenda.items.length > 0,
|
||||
fingerprintChanged:
|
||||
Boolean(previous?.agenda.fingerprint) &&
|
||||
previous?.agenda.fingerprint !== agenda.fingerprint,
|
||||
...(previous?.agenda.fingerprint
|
||||
? { previousFingerprint: previous.agenda.fingerprint }
|
||||
: {}),
|
||||
...(context.triggerReasons?.length
|
||||
? { triggerReasons: [...new Set(context.triggerReasons)].sort() }
|
||||
: {}),
|
||||
},
|
||||
evaluatedAt: nowIso,
|
||||
diagnostics: [
|
||||
...agenda.diagnostics,
|
||||
...(!teamActive ? ['team_runtime_inactive'] : []),
|
||||
...decision.diagnostics,
|
||||
],
|
||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
});
|
||||
|
||||
await this.deps.statusStore.write(status);
|
||||
if ((context.reconciledBy ?? 'request') === 'queue') {
|
||||
await this.planNudgeOutbox(status);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
private async planNudgeOutbox(status: MemberWorkSyncStatus): Promise<void> {
|
||||
const result = await this.nudgeOutboxPlanner.plan(status);
|
||||
if (result.code !== 'outbox_unavailable' && result.code !== 'status_not_nudgeable') {
|
||||
this.deps.logger?.debug('member work sync nudge outbox planning result', {
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
code: result.code,
|
||||
planned: result.planned,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function attachMemberWorkSyncReportToken(
|
||||
deps: MemberWorkSyncUseCaseDeps,
|
||||
status: MemberWorkSyncStatus
|
||||
): Promise<MemberWorkSyncStatus> {
|
||||
if (!deps.reportToken) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const issued = await deps.reportToken.create({
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
agendaFingerprint: status.agenda.fingerprint,
|
||||
issuedAt: status.evaluatedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
...status,
|
||||
reportToken: issued.token,
|
||||
reportTokenExpiresAt: issued.expiresAt,
|
||||
diagnostics: [...status.diagnostics, 'report_token_issued'],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { validateMemberWorkSyncReport } from '../domain';
|
||||
|
||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||
import {
|
||||
attachMemberWorkSyncReportToken,
|
||||
finalizeMemberWorkSyncAgenda,
|
||||
MemberWorkSyncReconciler,
|
||||
} from './MemberWorkSyncReconciler';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncReport,
|
||||
MemberWorkSyncReportRequest,
|
||||
MemberWorkSyncReportResult,
|
||||
MemberWorkSyncStatus,
|
||||
} from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
export class MemberWorkSyncReporter {
|
||||
private readonly reconciler: MemberWorkSyncReconciler;
|
||||
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {
|
||||
this.reconciler = new MemberWorkSyncReconciler(deps);
|
||||
}
|
||||
|
||||
async execute(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> {
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: request.teamName,
|
||||
memberName: request.memberName,
|
||||
event: 'report_received',
|
||||
source: 'reporter',
|
||||
agendaFingerprint: request.agendaFingerprint,
|
||||
state: request.state,
|
||||
...(request.taskIds?.length
|
||||
? {
|
||||
taskRefs: request.taskIds.map((taskId) => ({
|
||||
taskId,
|
||||
teamName: request.teamName,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const source = await this.deps.agendaSource.loadAgenda(request);
|
||||
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(agenda.teamName)
|
||||
: true;
|
||||
if (!teamActive) {
|
||||
const status = await this.reconciler.execute(request);
|
||||
const rejectedStatus = await this.recordRejectedReport(
|
||||
status,
|
||||
request,
|
||||
'team_runtime_inactive'
|
||||
);
|
||||
return {
|
||||
accepted: false,
|
||||
code: 'team_runtime_inactive',
|
||||
message: 'Team runtime is not active. Restart the team before reporting work sync state.',
|
||||
status: rejectedStatus,
|
||||
};
|
||||
}
|
||||
const tokenValidation = this.deps.reportToken
|
||||
? await this.deps.reportToken.verify({
|
||||
token: request.reportToken,
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
nowIso,
|
||||
})
|
||||
: ({ ok: false, reason: 'missing' } as const);
|
||||
const validation = validateMemberWorkSyncReport({
|
||||
request,
|
||||
agenda,
|
||||
nowIso,
|
||||
activeMemberNames: source.activeMemberNames,
|
||||
tokenValidation,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
const status = await this.reconciler.execute(request);
|
||||
const rejectedStatus = await this.recordRejectedReport(status, request, validation.code);
|
||||
return {
|
||||
accepted: false,
|
||||
code: validation.code,
|
||||
message: validation.message,
|
||||
status: rejectedStatus,
|
||||
};
|
||||
}
|
||||
|
||||
const report: MemberWorkSyncReport = {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
state: request.state,
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
reportedAt: nowIso,
|
||||
...(validation.expiresAt ? { expiresAt: validation.expiresAt } : {}),
|
||||
...(request.taskIds ? { taskIds: [...request.taskIds] } : {}),
|
||||
...(request.note ? { note: request.note } : {}),
|
||||
source: request.source ?? 'app',
|
||||
accepted: true,
|
||||
};
|
||||
|
||||
const status = await attachMemberWorkSyncReportToken(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
state:
|
||||
report.state === 'caught_up'
|
||||
? ('caught_up' as const)
|
||||
: report.state === 'blocked'
|
||||
? ('blocked' as const)
|
||||
: ('still_working' as const),
|
||||
agenda,
|
||||
report,
|
||||
shadow: {
|
||||
reconciledBy: 'report',
|
||||
wouldNudge: false,
|
||||
fingerprintChanged: false,
|
||||
},
|
||||
evaluatedAt: nowIso,
|
||||
diagnostics: [...agenda.diagnostics, 'report_accepted'],
|
||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
});
|
||||
|
||||
await this.deps.statusStore.write(status);
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
event: 'report_accepted',
|
||||
source: 'reporter',
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
state: status.state,
|
||||
actionableCount: agenda.items.length,
|
||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
});
|
||||
return {
|
||||
accepted: true,
|
||||
code: 'accepted',
|
||||
message: validation.message,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
private async recordRejectedReport(
|
||||
status: MemberWorkSyncStatus,
|
||||
request: MemberWorkSyncReportRequest,
|
||||
rejectionCode: string
|
||||
): Promise<MemberWorkSyncStatus> {
|
||||
const rejectedStatus: MemberWorkSyncStatus = {
|
||||
...status,
|
||||
report: {
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
state: request.state,
|
||||
agendaFingerprint: request.agendaFingerprint,
|
||||
reportedAt: status.evaluatedAt,
|
||||
...(request.taskIds ? { taskIds: [...request.taskIds] } : {}),
|
||||
...(request.note ? { note: request.note } : {}),
|
||||
source: request.source ?? 'app',
|
||||
accepted: false,
|
||||
rejectionCode,
|
||||
},
|
||||
diagnostics: [...status.diagnostics, `report_rejected:${rejectionCode}`],
|
||||
};
|
||||
await this.deps.statusStore.write(rejectedStatus);
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
event: 'report_rejected',
|
||||
source: 'reporter',
|
||||
agendaFingerprint: request.agendaFingerprint,
|
||||
state: request.state,
|
||||
actionableCount: status.agenda.items.length,
|
||||
reason: rejectionCode,
|
||||
...(status.providerId ? { providerId: status.providerId } : {}),
|
||||
});
|
||||
return rejectedStatus;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||
|
||||
import type { RuntimeTurnSettledEvent } from '../domain';
|
||||
import type {
|
||||
MemberWorkSyncAuditJournalPort,
|
||||
MemberWorkSyncClockPort,
|
||||
MemberWorkSyncLoggerPort,
|
||||
} from './ports';
|
||||
import type {
|
||||
RuntimeTurnSettledEventStorePort,
|
||||
RuntimeTurnSettledPayloadNormalizerPort,
|
||||
RuntimeTurnSettledReconcileQueuePort,
|
||||
RuntimeTurnSettledTargetResolverPort,
|
||||
} from './RuntimeTurnSettledPorts';
|
||||
|
||||
export interface RuntimeTurnSettledIngestorDeps {
|
||||
eventStore: RuntimeTurnSettledEventStorePort;
|
||||
normalizer: RuntimeTurnSettledPayloadNormalizerPort;
|
||||
targetResolver: RuntimeTurnSettledTargetResolverPort;
|
||||
reconcileQueue: RuntimeTurnSettledReconcileQueuePort;
|
||||
clock: MemberWorkSyncClockPort;
|
||||
auditJournal?: MemberWorkSyncAuditJournalPort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledDrainSummary {
|
||||
claimed: number;
|
||||
enqueued: number;
|
||||
unresolved: number;
|
||||
ignored: number;
|
||||
invalid: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
const NON_TERMINAL_OPENCODE_OUTCOMES = new Set([
|
||||
'timeout',
|
||||
'stream_unavailable',
|
||||
'prompt_rejected',
|
||||
'idle_without_assistant_activity',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function getIgnoredReason(event: RuntimeTurnSettledEvent): string | null {
|
||||
if (event.provider !== 'opencode') {
|
||||
return null;
|
||||
}
|
||||
const outcome = event.outcome?.trim();
|
||||
if (!outcome || !NON_TERMINAL_OPENCODE_OUTCOMES.has(outcome)) {
|
||||
return null;
|
||||
}
|
||||
return `opencode_non_terminal_outcome:${outcome}`;
|
||||
}
|
||||
|
||||
export class RuntimeTurnSettledIngestor {
|
||||
constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {}
|
||||
|
||||
async drainPending(limit: number = 50): Promise<RuntimeTurnSettledDrainSummary> {
|
||||
const summary: RuntimeTurnSettledDrainSummary = {
|
||||
claimed: 0,
|
||||
enqueued: 0,
|
||||
unresolved: 0,
|
||||
ignored: 0,
|
||||
invalid: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
const payloads = await this.deps.eventStore.claimPending(limit);
|
||||
summary.claimed = payloads.length;
|
||||
|
||||
for (const payload of payloads) {
|
||||
const processedAt = this.deps.clock.now().toISOString();
|
||||
try {
|
||||
const normalized = this.deps.normalizer.normalize({
|
||||
provider: payload.provider,
|
||||
raw: payload.raw,
|
||||
recordedAt: payload.claimedAt,
|
||||
});
|
||||
|
||||
if (!normalized.ok) {
|
||||
summary.invalid += 1;
|
||||
await this.deps.eventStore.markInvalid(payload, {
|
||||
reason: normalized.reason,
|
||||
processedAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalized.event.teamName && normalized.event.memberName) {
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: normalized.event.teamName,
|
||||
memberName: normalized.event.memberName,
|
||||
event: 'turn_settled_claimed',
|
||||
source: 'runtime_turn_settled_ingestor',
|
||||
reason: normalized.event.provider,
|
||||
metadata: {
|
||||
sourceId: normalized.event.sourceId,
|
||||
provider: normalized.event.provider,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ignoredReason = getIgnoredReason(normalized.event);
|
||||
if (ignoredReason) {
|
||||
summary.ignored += 1;
|
||||
await this.deps.eventStore.markProcessed(payload, {
|
||||
event: normalized.event,
|
||||
outcome: 'ignored',
|
||||
reason: ignoredReason,
|
||||
processedAt,
|
||||
});
|
||||
if (normalized.event.teamName && normalized.event.memberName) {
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: normalized.event.teamName,
|
||||
memberName: normalized.event.memberName,
|
||||
event: 'turn_settled_ignored',
|
||||
source: 'runtime_turn_settled_ingestor',
|
||||
reason: ignoredReason,
|
||||
metadata: {
|
||||
sourceId: normalized.event.sourceId,
|
||||
provider: normalized.event.provider,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolution = await this.deps.targetResolver.resolve(normalized.event);
|
||||
if (!resolution.ok) {
|
||||
summary.unresolved += 1;
|
||||
await this.deps.eventStore.markProcessed(payload, {
|
||||
event: normalized.event,
|
||||
outcome: 'unresolved',
|
||||
reason: resolution.reason,
|
||||
processedAt,
|
||||
});
|
||||
if (normalized.event.teamName && normalized.event.memberName) {
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: normalized.event.teamName,
|
||||
memberName: normalized.event.memberName,
|
||||
event: 'turn_settled_unresolved',
|
||||
source: 'runtime_turn_settled_ingestor',
|
||||
reason: resolution.reason,
|
||||
metadata: {
|
||||
sourceId: normalized.event.sourceId,
|
||||
provider: normalized.event.provider,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this.deps.reconcileQueue.enqueueRuntimeTurnSettled({
|
||||
teamName: resolution.teamName,
|
||||
memberName: resolution.memberName,
|
||||
event: normalized.event,
|
||||
});
|
||||
summary.enqueued += 1;
|
||||
await this.deps.eventStore.markProcessed(payload, {
|
||||
event: normalized.event,
|
||||
teamName: resolution.teamName,
|
||||
memberName: resolution.memberName,
|
||||
outcome: 'enqueued',
|
||||
processedAt,
|
||||
});
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: resolution.teamName,
|
||||
memberName: resolution.memberName,
|
||||
event: 'turn_settled_resolved',
|
||||
source: 'runtime_turn_settled_ingestor',
|
||||
reason: normalized.event.provider,
|
||||
metadata: {
|
||||
sourceId: normalized.event.sourceId,
|
||||
provider: normalized.event.provider,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
summary.failed += 1;
|
||||
this.deps.logger?.warn('runtime turn settled ingest failed', {
|
||||
filePath: payload.filePath,
|
||||
provider: payload.provider,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { RuntimeTurnSettledEvent, RuntimeTurnSettledProvider } from '../domain';
|
||||
|
||||
export interface RuntimeTurnSettledClaimedPayload {
|
||||
id: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
claimedAt: string;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledEventStorePort {
|
||||
claimPending(limit: number): Promise<RuntimeTurnSettledClaimedPayload[]>;
|
||||
markProcessed(
|
||||
payload: RuntimeTurnSettledClaimedPayload,
|
||||
result: RuntimeTurnSettledProcessedResult
|
||||
): Promise<void>;
|
||||
markInvalid(
|
||||
payload: RuntimeTurnSettledClaimedPayload,
|
||||
result: RuntimeTurnSettledInvalidResult
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledProcessedResult {
|
||||
event: RuntimeTurnSettledEvent;
|
||||
teamName?: string;
|
||||
memberName?: string;
|
||||
outcome: 'enqueued' | 'unresolved' | 'duplicate' | 'ignored';
|
||||
reason?: string;
|
||||
processedAt: string;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledInvalidResult {
|
||||
reason: string;
|
||||
processedAt: string;
|
||||
}
|
||||
|
||||
export type RuntimeTurnSettledPayloadNormalization =
|
||||
| { ok: true; event: RuntimeTurnSettledEvent }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export interface RuntimeTurnSettledPayloadNormalizerPort {
|
||||
normalize(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
recordedAt: string;
|
||||
}): RuntimeTurnSettledPayloadNormalization;
|
||||
}
|
||||
|
||||
export type RuntimeTurnSettledTargetResolution =
|
||||
| { ok: true; teamName: string; memberName: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export interface RuntimeTurnSettledTargetResolverPort {
|
||||
resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution>;
|
||||
}
|
||||
|
||||
export interface RuntimeTurnSettledReconcileQueuePort {
|
||||
enqueueRuntimeTurnSettled(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
event: RuntimeTurnSettledEvent;
|
||||
}): void;
|
||||
}
|
||||
11
src/features/member-work-sync/core/application/index.ts
Normal file
11
src/features/member-work-sync/core/application/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export * from './MemberWorkSyncAudit';
|
||||
export * from './MemberWorkSyncDiagnosticsReader';
|
||||
export * from './MemberWorkSyncMetricsReader';
|
||||
export * from './MemberWorkSyncNudgeDispatcher';
|
||||
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
||||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||
export * from './MemberWorkSyncReconciler';
|
||||
export * from './MemberWorkSyncReporter';
|
||||
export type * from './ports';
|
||||
export * from './RuntimeTurnSettledIngestor';
|
||||
export type * from './RuntimeTurnSettledPorts';
|
||||
202
src/features/member-work-sync/core/application/ports.ts
Normal file
202
src/features/member-work-sync/core/application/ports.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import type {
|
||||
MemberWorkSyncAgenda,
|
||||
MemberWorkSyncOutboxClaimInput,
|
||||
MemberWorkSyncOutboxCountRecentDeliveredInput,
|
||||
MemberWorkSyncOutboxEnsureInput,
|
||||
MemberWorkSyncOutboxEnsureResult,
|
||||
MemberWorkSyncOutboxItem,
|
||||
MemberWorkSyncOutboxMarkDeliveredInput,
|
||||
MemberWorkSyncOutboxMarkFailedInput,
|
||||
MemberWorkSyncOutboxMarkSupersededInput,
|
||||
MemberWorkSyncProviderId,
|
||||
MemberWorkSyncReport,
|
||||
MemberWorkSyncReportIntent,
|
||||
MemberWorkSyncReportIntentStatus,
|
||||
MemberWorkSyncReportRequest,
|
||||
MemberWorkSyncStatus,
|
||||
MemberWorkSyncTeamMetrics,
|
||||
} from '../../contracts';
|
||||
|
||||
export interface MemberWorkSyncClockPort {
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncHashPort {
|
||||
sha256Hex(value: string): string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncReportTokenCreateInput {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
agendaFingerprint: string;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncReportTokenVerifyInput {
|
||||
token?: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
agendaFingerprint: string;
|
||||
nowIso: string;
|
||||
}
|
||||
|
||||
export type MemberWorkSyncReportTokenVerification =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'missing' | 'expired' | 'invalid' };
|
||||
|
||||
export interface MemberWorkSyncReportTokenPort {
|
||||
create(input: MemberWorkSyncReportTokenCreateInput): Promise<{
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}>;
|
||||
verify(
|
||||
input: MemberWorkSyncReportTokenVerifyInput
|
||||
): Promise<MemberWorkSyncReportTokenVerification>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncLifecyclePort {
|
||||
isTeamActive(teamName: string): Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncLoggerPort {
|
||||
debug(message: string, metadata?: Record<string, unknown>): void;
|
||||
warn(message: string, metadata?: Record<string, unknown>): void;
|
||||
error(message: string, metadata?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export type MemberWorkSyncAuditEventName =
|
||||
| 'turn_settled_claimed'
|
||||
| 'turn_settled_resolved'
|
||||
| 'turn_settled_unresolved'
|
||||
| 'turn_settled_ignored'
|
||||
| 'queue_enqueued'
|
||||
| 'queue_coalesced'
|
||||
| 'queue_reconciled'
|
||||
| 'queue_dropped'
|
||||
| 'reconcile_started'
|
||||
| 'agenda_loaded'
|
||||
| 'decision_made'
|
||||
| 'status_written'
|
||||
| 'report_received'
|
||||
| 'report_accepted'
|
||||
| 'report_rejected'
|
||||
| 'nudge_planned'
|
||||
| 'nudge_delivered'
|
||||
| 'nudge_skipped'
|
||||
| 'nudge_retryable'
|
||||
| 'nudge_superseded'
|
||||
| 'watchdog_cooldown_active'
|
||||
| 'member_busy'
|
||||
| 'team_inactive'
|
||||
| 'index_repaired'
|
||||
| 'legacy_fallback_used';
|
||||
|
||||
export interface MemberWorkSyncAuditEvent {
|
||||
timestamp: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
event: MemberWorkSyncAuditEventName;
|
||||
source: string;
|
||||
agendaFingerprint?: string;
|
||||
state?: string;
|
||||
actionableCount?: number;
|
||||
reason?: string;
|
||||
triggerReasons?: string[];
|
||||
providerId?: string;
|
||||
taskRefs?: { taskId: string; displayId?: string; teamName?: string }[];
|
||||
diagnostics?: string[];
|
||||
messagePreview?: string;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncAuditJournalPort {
|
||||
append(event: MemberWorkSyncAuditEvent): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncAgendaSourceResult {
|
||||
agenda: Omit<MemberWorkSyncAgenda, 'fingerprint'>;
|
||||
activeMemberNames: string[];
|
||||
inactive: boolean;
|
||||
providerId?: MemberWorkSyncProviderId;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncAgendaSourcePort {
|
||||
loadAgenda(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<MemberWorkSyncAgendaSourceResult>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncStatusStorePort {
|
||||
read(input: { teamName: string; memberName: string }): Promise<MemberWorkSyncStatus | null>;
|
||||
write(status: MemberWorkSyncStatus): Promise<void>;
|
||||
readTeamMetrics?(teamName: string): Promise<MemberWorkSyncTeamMetrics>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncReportStorePort {
|
||||
appendPendingReport?(request: MemberWorkSyncReportRequest, reason: string): Promise<void>;
|
||||
listPendingReports?(teamName: string): Promise<MemberWorkSyncReportIntent[]>;
|
||||
markPendingReportProcessed?(
|
||||
teamName: string,
|
||||
id: string,
|
||||
result: { status: MemberWorkSyncReportIntentStatus; resultCode: string; processedAt: string }
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncOutboxStorePort {
|
||||
ensurePending(input: MemberWorkSyncOutboxEnsureInput): Promise<MemberWorkSyncOutboxEnsureResult>;
|
||||
claimDue(input: MemberWorkSyncOutboxClaimInput): Promise<MemberWorkSyncOutboxItem[]>;
|
||||
markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void>;
|
||||
markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void>;
|
||||
markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void>;
|
||||
countRecentDelivered(input: MemberWorkSyncOutboxCountRecentDeliveredInput): Promise<number>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncInboxNudgePort {
|
||||
insertIfAbsent(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
messageId: string;
|
||||
payloadHash: string;
|
||||
payload: MemberWorkSyncOutboxItem['payload'];
|
||||
timestamp: string;
|
||||
}): Promise<{ inserted: boolean; messageId: string; conflict?: boolean }>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncWatchdogCooldownPort {
|
||||
hasRecentNudge(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
taskIds: string[];
|
||||
nowIso: string;
|
||||
}): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncBusySignalPort {
|
||||
isBusy(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
nowIso: string;
|
||||
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncUseCaseDeps {
|
||||
clock: MemberWorkSyncClockPort;
|
||||
hash: MemberWorkSyncHashPort;
|
||||
agendaSource: MemberWorkSyncAgendaSourcePort;
|
||||
statusStore: MemberWorkSyncStatusStorePort;
|
||||
reportStore?: MemberWorkSyncReportStorePort;
|
||||
outboxStore?: MemberWorkSyncOutboxStorePort;
|
||||
inboxNudge?: MemberWorkSyncInboxNudgePort;
|
||||
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
|
||||
busySignal?: MemberWorkSyncBusySignalPort;
|
||||
reportToken?: MemberWorkSyncReportTokenPort;
|
||||
auditJournal?: MemberWorkSyncAuditJournalPort;
|
||||
lifecycle?: MemberWorkSyncLifecyclePort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
export interface LatestAcceptedReportLookup {
|
||||
latestAcceptedReport?: MemberWorkSyncReport;
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import {
|
||||
buildAgendaFingerprintPayload,
|
||||
canonicalizeAgendaFingerprintPayload,
|
||||
formatAgendaFingerprint,
|
||||
} from './AgendaFingerprint';
|
||||
import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle';
|
||||
import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncActionableWorkItem,
|
||||
MemberWorkSyncAgenda,
|
||||
MemberWorkSyncProviderId,
|
||||
} from '../../contracts';
|
||||
|
||||
export interface MemberWorkSyncTaskLike {
|
||||
id: string;
|
||||
displayId?: string;
|
||||
subject?: string;
|
||||
status: string;
|
||||
owner?: string | null;
|
||||
reviewState?: string | null;
|
||||
needsClarification?: 'lead' | 'user' | null;
|
||||
blockedBy?: string[];
|
||||
blocks?: string[];
|
||||
deletedAt?: string | null;
|
||||
historyEvents?: ReviewHistoryEventLike[];
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncMemberLike {
|
||||
name: string;
|
||||
providerId?: MemberWorkSyncProviderId | string;
|
||||
model?: string;
|
||||
agentType?: string;
|
||||
removedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface BuildActionableWorkAgendaInput {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
generatedAt: string;
|
||||
tasks: MemberWorkSyncTaskLike[];
|
||||
members: MemberWorkSyncMemberLike[];
|
||||
kanbanReviewersByTaskId?: Record<string, string | null | undefined>;
|
||||
sourceRevision?: string;
|
||||
hash: (canonicalPayload: string) => string;
|
||||
}
|
||||
|
||||
function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean {
|
||||
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
|
||||
}
|
||||
|
||||
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
|
||||
return new Set(
|
||||
members
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => normalizeMemberName(member.name))
|
||||
.filter((name) => name.length > 0 && !isReservedMemberName(name))
|
||||
);
|
||||
}
|
||||
|
||||
function buildBaseItem(
|
||||
task: MemberWorkSyncTaskLike,
|
||||
memberName: string
|
||||
): Omit<MemberWorkSyncActionableWorkItem, 'kind' | 'priority' | 'reason' | 'evidence'> {
|
||||
return {
|
||||
taskId: task.id,
|
||||
...(task.displayId ? { displayId: task.displayId } : {}),
|
||||
subject: task.subject?.trim() || 'Untitled task',
|
||||
assignee: memberName,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildActionableWorkAgenda(
|
||||
input: BuildActionableWorkAgendaInput
|
||||
): MemberWorkSyncAgenda {
|
||||
const memberName = normalizeMemberName(input.memberName);
|
||||
const diagnostics: string[] = [];
|
||||
const activeMemberNames = getActiveMemberNames(input.members);
|
||||
|
||||
if (!memberName || isReservedMemberName(memberName)) {
|
||||
diagnostics.push('member_invalid_or_reserved');
|
||||
} else if (!activeMemberNames.has(memberName)) {
|
||||
diagnostics.push('member_not_active');
|
||||
}
|
||||
|
||||
const items: MemberWorkSyncActionableWorkItem[] = [];
|
||||
|
||||
if (activeMemberNames.has(memberName)) {
|
||||
for (const task of input.tasks) {
|
||||
if (!task.id || isCompletedOrDeleted(task)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const owner = normalizeMemberName(task.owner);
|
||||
const base = buildBaseItem(task, memberName);
|
||||
const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort();
|
||||
const blocks = [...(task.blocks ?? [])].filter(Boolean).sort();
|
||||
|
||||
const reviewOwner = resolveCurrentReviewOwner({
|
||||
reviewState: task.reviewState,
|
||||
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
|
||||
historyEvents: task.historyEvents,
|
||||
});
|
||||
|
||||
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'review',
|
||||
priority: 'review_requested',
|
||||
reason: 'current_cycle_review_assigned',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
...(owner ? { owner } : {}),
|
||||
reviewer: memberName,
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
...(reviewOwner.historyEventIds.length > 0
|
||||
? { historyEventIds: reviewOwner.historyEventIds }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sameMemberName(owner, memberName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.needsClarification === 'lead' || task.needsClarification === 'user') {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'clarification',
|
||||
priority: 'needs_clarification',
|
||||
reason: `task_needs_${task.needsClarification}_clarification`,
|
||||
evidence: {
|
||||
status: task.status,
|
||||
owner: memberName,
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
needsClarification: task.needsClarification,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockedBy.length > 0) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'blocked_dependency',
|
||||
priority: 'blocked',
|
||||
reason: 'owned_task_has_blocked_dependency',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
owner: memberName,
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
blockedByTaskIds: blockedBy,
|
||||
...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.status === 'pending' || task.status === 'in_progress') {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'work',
|
||||
priority: 'normal',
|
||||
reason: task.status === 'pending' ? 'owned_pending_task' : 'owned_in_progress_task',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
owner: memberName,
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = buildAgendaFingerprintPayload({
|
||||
teamName: input.teamName,
|
||||
memberName,
|
||||
items,
|
||||
sourceRevision: input.sourceRevision,
|
||||
});
|
||||
const canonicalPayload = canonicalizeAgendaFingerprintPayload(payload);
|
||||
|
||||
return {
|
||||
teamName: input.teamName,
|
||||
memberName,
|
||||
generatedAt: input.generatedAt,
|
||||
fingerprint: formatAgendaFingerprint(input.hash(canonicalPayload)),
|
||||
items,
|
||||
diagnostics,
|
||||
...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { MemberWorkSyncActionableWorkItem } from '../../contracts';
|
||||
|
||||
export const MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX = 'agenda:v1:';
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableJson(item)).join(',')}]`;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
export interface AgendaFingerprintPayload {
|
||||
version: 1;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
items: {
|
||||
taskId: string;
|
||||
displayId?: string;
|
||||
subject: string;
|
||||
kind: string;
|
||||
assignee: string;
|
||||
priority: string;
|
||||
evidence: MemberWorkSyncActionableWorkItem['evidence'];
|
||||
}[];
|
||||
sourceRevision?: string;
|
||||
}
|
||||
|
||||
export function buildAgendaFingerprintPayload(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
items: MemberWorkSyncActionableWorkItem[];
|
||||
sourceRevision?: string;
|
||||
}): AgendaFingerprintPayload {
|
||||
return {
|
||||
version: 1,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}),
|
||||
items: [...input.items]
|
||||
.sort((left, right) => {
|
||||
const leftKey = `${left.kind}:${left.taskId}:${left.displayId ?? ''}`;
|
||||
const rightKey = `${right.kind}:${right.taskId}:${right.displayId ?? ''}`;
|
||||
return leftKey.localeCompare(rightKey);
|
||||
})
|
||||
.map((item) => ({
|
||||
taskId: item.taskId,
|
||||
...(item.displayId ? { displayId: item.displayId } : {}),
|
||||
subject: item.subject,
|
||||
kind: item.kind,
|
||||
assignee: item.assignee,
|
||||
priority: item.priority,
|
||||
evidence: {
|
||||
...item.evidence,
|
||||
...(item.evidence.blockerTaskIds
|
||||
? { blockerTaskIds: [...item.evidence.blockerTaskIds].sort() }
|
||||
: {}),
|
||||
...(item.evidence.blockedByTaskIds
|
||||
? { blockedByTaskIds: [...item.evidence.blockedByTaskIds].sort() }
|
||||
: {}),
|
||||
...(item.evidence.historyEventIds
|
||||
? { historyEventIds: [...item.evidence.historyEventIds].sort() }
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalizeAgendaFingerprintPayload(payload: AgendaFingerprintPayload): string {
|
||||
return stableJson(payload);
|
||||
}
|
||||
|
||||
export function formatAgendaFingerprint(hashHex: string): string {
|
||||
return `${MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX}${hashHex}`;
|
||||
}
|
||||
105
src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts
Normal file
105
src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type {
|
||||
MemberWorkSyncNudgePayload,
|
||||
MemberWorkSyncOutboxEnsureInput,
|
||||
MemberWorkSyncStatus,
|
||||
} from '../../contracts';
|
||||
|
||||
export const MEMBER_WORK_SYNC_NUDGE_ID_PREFIX = 'member-work-sync';
|
||||
|
||||
interface MemberWorkSyncNudgeHash {
|
||||
sha256Hex(value: string): string;
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableJson).join(',')}]`;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncNudgeId(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
agendaFingerprint: string;
|
||||
}): string {
|
||||
return [
|
||||
MEMBER_WORK_SYNC_NUDGE_ID_PREFIX,
|
||||
input.teamName,
|
||||
input.memberName.trim().toLowerCase(),
|
||||
input.agendaFingerprint,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload {
|
||||
const taskRefs = status.agenda.items.map((item) => ({
|
||||
teamName: status.teamName,
|
||||
taskId: item.taskId,
|
||||
displayId: item.displayId ?? item.taskId.slice(0, 8),
|
||||
}));
|
||||
const preview = status.agenda.items
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`)
|
||||
.join('; ');
|
||||
|
||||
return {
|
||||
from: 'system',
|
||||
to: status.memberName,
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
source: 'member-work-sync',
|
||||
actionMode: 'do',
|
||||
taskRefs,
|
||||
text: [
|
||||
'Work sync check: you have current actionable work assigned.',
|
||||
preview ? `Current agenda: ${preview}.` : '',
|
||||
'Continue concrete task work, report a real blocker with task tools, or call member_work_sync_report for the current fingerprint.',
|
||||
'Do not reply only with acknowledgement.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncNudgePayloadHash(
|
||||
hash: MemberWorkSyncNudgeHash,
|
||||
payload: MemberWorkSyncNudgePayload
|
||||
): string {
|
||||
return hash.sha256Hex(stableJson(payload));
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncOutboxEnsureInput(input: {
|
||||
status: MemberWorkSyncStatus;
|
||||
hash: MemberWorkSyncNudgeHash;
|
||||
nowIso: string;
|
||||
}): MemberWorkSyncOutboxEnsureInput | null {
|
||||
const status = input.status;
|
||||
if (
|
||||
status.state !== 'needs_sync' ||
|
||||
status.shadow?.wouldNudge !== true ||
|
||||
status.agenda.items.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = buildMemberWorkSyncNudgePayload(status);
|
||||
return {
|
||||
id: buildMemberWorkSyncNudgeId({
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
agendaFingerprint: status.agenda.fingerprint,
|
||||
}),
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
agendaFingerprint: status.agenda.fingerprint,
|
||||
payloadHash: buildMemberWorkSyncNudgePayloadHash(input.hash, payload),
|
||||
payload,
|
||||
nowIso: input.nowIso,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import type {
|
||||
MemberWorkSyncMetricEvent,
|
||||
MemberWorkSyncPhase2ReadinessAssessment,
|
||||
MemberWorkSyncPhase2ReadinessReason,
|
||||
MemberWorkSyncPhase2ReadinessThresholds,
|
||||
} from '../../contracts';
|
||||
|
||||
export const DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS: MemberWorkSyncPhase2ReadinessThresholds =
|
||||
{
|
||||
minObservedMembers: 1,
|
||||
minStatusEvents: 20,
|
||||
minObservationHours: 1,
|
||||
maxWouldNudgesPerMemberHour: 2,
|
||||
maxFingerprintChangesPerMemberHour: 1,
|
||||
maxReportRejectionRate: 0.2,
|
||||
};
|
||||
|
||||
interface AssessMemberWorkSyncPhase2ReadinessInput {
|
||||
memberCount: number;
|
||||
recentEvents: MemberWorkSyncMetricEvent[];
|
||||
thresholds?: Partial<MemberWorkSyncPhase2ReadinessThresholds>;
|
||||
}
|
||||
|
||||
function parseTime(value: string): number | null {
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function getObservationHours(events: MemberWorkSyncMetricEvent[]): number {
|
||||
const times = events.flatMap((event) => {
|
||||
const time = parseTime(event.recordedAt);
|
||||
return time == null ? [] : [time];
|
||||
});
|
||||
if (times.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
return Math.max(0, (max - min) / 3_600_000);
|
||||
}
|
||||
|
||||
function roundRate(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function pushIf(
|
||||
reasons: MemberWorkSyncPhase2ReadinessReason[],
|
||||
condition: boolean,
|
||||
reason: MemberWorkSyncPhase2ReadinessReason
|
||||
): void {
|
||||
if (condition) {
|
||||
reasons.push(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export function assessMemberWorkSyncPhase2Readiness({
|
||||
memberCount,
|
||||
recentEvents,
|
||||
thresholds: thresholdOverrides,
|
||||
}: AssessMemberWorkSyncPhase2ReadinessInput): MemberWorkSyncPhase2ReadinessAssessment {
|
||||
const thresholds = {
|
||||
...DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS,
|
||||
...thresholdOverrides,
|
||||
};
|
||||
const statusEvents = recentEvents.filter((event) => event.kind === 'status_evaluated');
|
||||
const wouldNudgeEvents = recentEvents.filter((event) => event.kind === 'would_nudge');
|
||||
const fingerprintChangeEvents = recentEvents.filter(
|
||||
(event) => event.kind === 'fingerprint_changed'
|
||||
);
|
||||
const reportAcceptedEvents = recentEvents.filter((event) => event.kind === 'report_accepted');
|
||||
const reportRejectedEvents = recentEvents.filter((event) => event.kind === 'report_rejected');
|
||||
const observationHours = getObservationHours(recentEvents);
|
||||
const memberHourDenominator = Math.max(memberCount, 1) * Math.max(observationHours, 1 / 60);
|
||||
const wouldNudgesPerMemberHour = wouldNudgeEvents.length / memberHourDenominator;
|
||||
const fingerprintChangesPerMemberHour = fingerprintChangeEvents.length / memberHourDenominator;
|
||||
const reportEventCount = reportAcceptedEvents.length + reportRejectedEvents.length;
|
||||
const reportRejectionRate =
|
||||
reportEventCount > 0 ? reportRejectedEvents.length / reportEventCount : 0;
|
||||
|
||||
const collectingReasons: MemberWorkSyncPhase2ReadinessReason[] = [];
|
||||
pushIf(collectingReasons, memberCount < thresholds.minObservedMembers, 'insufficient_members');
|
||||
pushIf(
|
||||
collectingReasons,
|
||||
statusEvents.length < thresholds.minStatusEvents,
|
||||
'insufficient_status_events'
|
||||
);
|
||||
pushIf(
|
||||
collectingReasons,
|
||||
observationHours < thresholds.minObservationHours,
|
||||
'insufficient_observation_window'
|
||||
);
|
||||
|
||||
const blockingReasons: MemberWorkSyncPhase2ReadinessReason[] = [];
|
||||
pushIf(
|
||||
blockingReasons,
|
||||
wouldNudgesPerMemberHour > thresholds.maxWouldNudgesPerMemberHour,
|
||||
'would_nudge_rate_high'
|
||||
);
|
||||
pushIf(
|
||||
blockingReasons,
|
||||
fingerprintChangesPerMemberHour > thresholds.maxFingerprintChangesPerMemberHour,
|
||||
'fingerprint_churn_high'
|
||||
);
|
||||
pushIf(
|
||||
blockingReasons,
|
||||
reportRejectionRate > thresholds.maxReportRejectionRate,
|
||||
'report_rejection_rate_high'
|
||||
);
|
||||
|
||||
const state =
|
||||
collectingReasons.length > 0
|
||||
? 'collecting_shadow_data'
|
||||
: blockingReasons.length > 0
|
||||
? 'blocked'
|
||||
: 'shadow_ready';
|
||||
const reasons = [...collectingReasons, ...blockingReasons];
|
||||
|
||||
return {
|
||||
state,
|
||||
reasons,
|
||||
thresholds,
|
||||
rates: {
|
||||
observationHours: roundRate(observationHours),
|
||||
statusEventCount: statusEvents.length,
|
||||
wouldNudgesPerMemberHour: roundRate(wouldNudgesPerMemberHour),
|
||||
fingerprintChangesPerMemberHour: roundRate(fingerprintChangesPerMemberHour),
|
||||
reportRejectionRate: roundRate(reportRejectionRate),
|
||||
},
|
||||
diagnostics: reasons.map((reason) => `phase2_readiness:${reason}`),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncAgenda,
|
||||
MemberWorkSyncReportRequest,
|
||||
MemberWorkSyncReportState,
|
||||
} from '../../contracts';
|
||||
|
||||
export interface MemberWorkSyncReportValidation {
|
||||
ok: boolean;
|
||||
code: string;
|
||||
message: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export type MemberWorkSyncReportTokenValidation =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'missing' | 'expired' | 'invalid' };
|
||||
|
||||
const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000;
|
||||
const MIN_LEASE_MS = 60_000;
|
||||
const MAX_LEASE_MS = 60 * 60 * 1000;
|
||||
|
||||
function clampLeaseTtlMs(
|
||||
value: number | undefined,
|
||||
state: MemberWorkSyncReportState
|
||||
): number | undefined {
|
||||
if (state === 'caught_up') {
|
||||
return undefined;
|
||||
}
|
||||
const fallback = state === 'blocked' ? DEFAULT_BLOCKED_LEASE_MS : DEFAULT_STILL_WORKING_LEASE_MS;
|
||||
const numeric = Number.isFinite(value) ? Math.floor(Number(value)) : fallback;
|
||||
return Math.min(MAX_LEASE_MS, Math.max(MIN_LEASE_MS, numeric));
|
||||
}
|
||||
|
||||
function agendaHasBlockedEvidence(
|
||||
agenda: MemberWorkSyncAgenda,
|
||||
taskIds: string[] | undefined
|
||||
): boolean {
|
||||
const targetIds = new Set((taskIds ?? []).filter(Boolean));
|
||||
return agenda.items.some((item) => {
|
||||
if (targetIds.size > 0 && !targetIds.has(item.taskId)) {
|
||||
return false;
|
||||
}
|
||||
return item.kind === 'blocked_dependency' || item.priority === 'blocked';
|
||||
});
|
||||
}
|
||||
|
||||
export function validateMemberWorkSyncReport(input: {
|
||||
request: MemberWorkSyncReportRequest;
|
||||
agenda: MemberWorkSyncAgenda;
|
||||
nowIso: string;
|
||||
activeMemberNames: string[];
|
||||
tokenValidation: MemberWorkSyncReportTokenValidation;
|
||||
}): MemberWorkSyncReportValidation {
|
||||
const memberName = normalizeMemberName(input.request.memberName);
|
||||
const activeMemberNames = new Set(input.activeMemberNames.map(normalizeMemberName));
|
||||
|
||||
if (!memberName || isReservedMemberName(memberName)) {
|
||||
return { ok: false, code: 'reserved_or_invalid_member', message: 'Invalid member identity.' };
|
||||
}
|
||||
if (!sameMemberName(memberName, input.agenda.memberName)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'identity_mismatch',
|
||||
message: 'Report member does not match agenda.',
|
||||
};
|
||||
}
|
||||
if (!activeMemberNames.has(memberName)) {
|
||||
return { ok: false, code: 'member_inactive', message: 'Member is not active in this team.' };
|
||||
}
|
||||
if (input.request.agendaFingerprint !== input.agenda.fingerprint) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'stale_fingerprint',
|
||||
message: 'Report fingerprint is stale. Read current member work sync status and retry.',
|
||||
};
|
||||
}
|
||||
if (!input.tokenValidation.ok) {
|
||||
return input.tokenValidation.reason === 'missing'
|
||||
? {
|
||||
ok: false,
|
||||
code: 'identity_untrusted',
|
||||
message: 'Report token is required. Read current member work sync status and retry.',
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
code: 'invalid_report_token',
|
||||
message:
|
||||
'Report token is invalid or expired. Read current member work sync status and retry.',
|
||||
};
|
||||
}
|
||||
|
||||
const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId));
|
||||
for (const taskId of input.request.taskIds ?? []) {
|
||||
if (!agendaTaskIds.has(taskId)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'foreign_task_id',
|
||||
message: `Task ${taskId} is not in the current actionable agenda.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (input.request.state === 'caught_up' && input.agenda.items.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'caught_up_rejected_actionable_items_exist',
|
||||
message: 'Cannot report caught_up while actionable work remains.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.request.state === 'blocked' &&
|
||||
!agendaHasBlockedEvidence(input.agenda, input.request.taskIds)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'blocked_without_evidence',
|
||||
message: 'Blocked report requires current blocker evidence in the task board.',
|
||||
};
|
||||
}
|
||||
|
||||
const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state);
|
||||
return {
|
||||
ok: true,
|
||||
code: 'accepted',
|
||||
message: 'Member work sync report accepted.',
|
||||
...(leaseTtlMs
|
||||
? { expiresAt: new Date(Date.parse(input.nowIso) + leaseTtlMs).toISOString() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { RuntimeTurnSettledProvider } from './RuntimeTurnSettledProvider';
|
||||
|
||||
export interface RuntimeTurnSettledEvent {
|
||||
schemaVersion: 1;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
hookEventName: 'Stop';
|
||||
sourceId: string;
|
||||
payloadHash: string;
|
||||
recordedAt: string;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
transcriptPath?: string;
|
||||
cwd?: string;
|
||||
teamName?: string;
|
||||
memberName?: string;
|
||||
agentId?: string;
|
||||
threadId?: string;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
export function buildRuntimeTurnSettledSourceId(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
transcriptPath?: string;
|
||||
payloadHash: string;
|
||||
}): string {
|
||||
return [
|
||||
'runtime-turn-settled',
|
||||
input.provider,
|
||||
input.sessionId?.trim() || 'no-session',
|
||||
input.turnId?.trim() || 'no-turn',
|
||||
input.transcriptPath?.trim() || 'no-transcript',
|
||||
input.payloadHash,
|
||||
].join(':');
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export type RuntimeTurnSettledProvider = 'claude' | 'codex' | 'opencode';
|
||||
|
||||
export function isRuntimeTurnSettledProvider(value: unknown): value is RuntimeTurnSettledProvider {
|
||||
return value === 'claude' || value === 'codex' || value === 'opencode';
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type {
|
||||
MemberWorkSyncAgenda,
|
||||
MemberWorkSyncReport,
|
||||
MemberWorkSyncStatusState,
|
||||
} from '../../contracts';
|
||||
|
||||
export interface SyncDecision {
|
||||
state: MemberWorkSyncStatusState;
|
||||
acceptedReport?: MemberWorkSyncReport;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export function decideMemberWorkSyncStatus(input: {
|
||||
agenda: MemberWorkSyncAgenda;
|
||||
latestAcceptedReport?: MemberWorkSyncReport | null;
|
||||
nowIso: string;
|
||||
inactive?: boolean;
|
||||
}): SyncDecision {
|
||||
if (input.inactive) {
|
||||
return { state: 'inactive', diagnostics: ['member_or_team_inactive'] };
|
||||
}
|
||||
|
||||
if (input.agenda.items.length === 0) {
|
||||
return {
|
||||
state: 'caught_up',
|
||||
diagnostics: ['agenda_empty'],
|
||||
acceptedReport:
|
||||
input.latestAcceptedReport?.agendaFingerprint === input.agenda.fingerprint
|
||||
? input.latestAcceptedReport
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const report = input.latestAcceptedReport ?? null;
|
||||
if (!report) {
|
||||
return { state: 'needs_sync', diagnostics: ['no_current_report'] };
|
||||
}
|
||||
if (report.agendaFingerprint !== input.agenda.fingerprint) {
|
||||
return { state: 'needs_sync', diagnostics: ['report_fingerprint_stale'] };
|
||||
}
|
||||
if (report.expiresAt && Date.parse(report.expiresAt) <= Date.parse(input.nowIso)) {
|
||||
return { state: 'needs_sync', diagnostics: ['report_lease_expired'] };
|
||||
}
|
||||
if (report.state === 'still_working') {
|
||||
return { state: 'still_working', acceptedReport: report, diagnostics: ['lease_still_working'] };
|
||||
}
|
||||
if (report.state === 'blocked') {
|
||||
return { state: 'blocked', acceptedReport: report, diagnostics: ['lease_blocked'] };
|
||||
}
|
||||
|
||||
return { state: 'needs_sync', diagnostics: ['caught_up_report_not_valid_for_non_empty_agenda'] };
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { normalizeMemberName } from './memberName';
|
||||
|
||||
export interface ReviewHistoryEventLike {
|
||||
id?: string;
|
||||
type: string;
|
||||
timestamp?: string;
|
||||
actor?: string;
|
||||
reviewer?: string;
|
||||
}
|
||||
|
||||
export interface CurrentReviewOwner {
|
||||
reviewer: string;
|
||||
historyEventIds: string[];
|
||||
}
|
||||
|
||||
function compareEventsByTimestamp(
|
||||
left: ReviewHistoryEventLike,
|
||||
right: ReviewHistoryEventLike
|
||||
): number {
|
||||
const leftTime = Date.parse(left.timestamp ?? '');
|
||||
const rightTime = Date.parse(right.timestamp ?? '');
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function resolveCurrentReviewOwner(input: {
|
||||
reviewState?: string | null;
|
||||
kanbanReviewer?: string | null;
|
||||
historyEvents?: ReviewHistoryEventLike[];
|
||||
}): CurrentReviewOwner | null {
|
||||
if (input.reviewState !== 'review') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const historyEvents = [...(input.historyEvents ?? [])]
|
||||
.filter((event) =>
|
||||
[
|
||||
'review_requested',
|
||||
'review_started',
|
||||
'review_approved',
|
||||
'review_changes_requested',
|
||||
].includes(event.type)
|
||||
)
|
||||
.sort(compareEventsByTimestamp);
|
||||
|
||||
const latest = historyEvents.at(-1);
|
||||
if (latest?.type === 'review_approved' || latest?.type === 'review_changes_requested') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestStarted = [...historyEvents]
|
||||
.reverse()
|
||||
.find((event) => event.type === 'review_started');
|
||||
const latestRequested = [...historyEvents]
|
||||
.reverse()
|
||||
.find((event) => event.type === 'review_requested');
|
||||
|
||||
const reviewer =
|
||||
normalizeMemberName(latestStarted?.actor) ||
|
||||
normalizeMemberName(latestRequested?.reviewer) ||
|
||||
normalizeMemberName(input.kanbanReviewer);
|
||||
|
||||
if (!reviewer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reviewer,
|
||||
historyEventIds: [latestStarted?.id, latestRequested?.id].filter(
|
||||
(id): id is string => typeof id === 'string' && id.length > 0
|
||||
),
|
||||
};
|
||||
}
|
||||
10
src/features/member-work-sync/core/domain/index.ts
Normal file
10
src/features/member-work-sync/core/domain/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export * from './ActionableWorkAgenda';
|
||||
export * from './AgendaFingerprint';
|
||||
export * from './currentReviewCycle';
|
||||
export * from './memberName';
|
||||
export * from './MemberWorkSyncNudge';
|
||||
export * from './MemberWorkSyncPhase2Readiness';
|
||||
export * from './MemberWorkSyncReportValidator';
|
||||
export * from './RuntimeTurnSettledEvent';
|
||||
export * from './RuntimeTurnSettledProvider';
|
||||
export * from './SyncDecisionPolicy';
|
||||
15
src/features/member-work-sync/core/domain/memberName.ts
Normal file
15
src/features/member-work-sync/core/domain/memberName.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
const RESERVED_MEMBER_NAMES = new Set(['', 'user', 'system']);
|
||||
|
||||
export function normalizeMemberName(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function isReservedMemberName(value: unknown): boolean {
|
||||
return RESERVED_MEMBER_NAMES.has(normalizeMemberName(value));
|
||||
}
|
||||
|
||||
export function sameMemberName(left: unknown, right: unknown): boolean {
|
||||
const normalizedLeft = normalizeMemberName(left);
|
||||
const normalizedRight = normalizeMemberName(right);
|
||||
return normalizedLeft.length > 0 && normalizedLeft === normalizedRight;
|
||||
}
|
||||
1
src/features/member-work-sync/index.ts
Normal file
1
src/features/member-work-sync/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './contracts';
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import type {
|
||||
MemberWorkSyncEventQueue,
|
||||
MemberWorkSyncTriggerReason,
|
||||
} from '../../infrastructure/MemberWorkSyncEventQueue';
|
||||
import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
|
||||
|
||||
interface MemberTurnSettledEventPayload {
|
||||
memberName?: string;
|
||||
sourceId?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
interface MemberWorkSyncRosterSource {
|
||||
loadActiveMemberNames(teamName: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
interface MemberWorkSyncMemberStorageMaterializer {
|
||||
materializeMember(teamName: string, memberName: string): Promise<void>;
|
||||
}
|
||||
|
||||
const TEAM_WIDE_REASONS: Partial<Record<TeamChangeEvent['type'], MemberWorkSyncTriggerReason>> = {
|
||||
config: 'config_changed',
|
||||
task: 'task_changed',
|
||||
'task-log-change': 'runtime_activity',
|
||||
'log-source-change': 'runtime_activity',
|
||||
process: 'runtime_activity',
|
||||
'lead-activity': 'runtime_activity',
|
||||
};
|
||||
|
||||
function parseInboxRecipient(detail: string | undefined): string | null {
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function parseToolActivity(detail: string | undefined): ToolActivityEventPayload | null {
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(detail) as ToolActivityEventPayload;
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMemberTurnSettled(detail: string | undefined): MemberTurnSettledEventPayload | null {
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(detail) as MemberTurnSettledEventPayload;
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class MemberWorkSyncTeamChangeRouter {
|
||||
constructor(
|
||||
private readonly rosterSource: MemberWorkSyncRosterSource,
|
||||
private readonly queue: MemberWorkSyncEventQueue,
|
||||
private readonly materializer?: MemberWorkSyncMemberStorageMaterializer
|
||||
) {}
|
||||
|
||||
async enqueueStartupScan(teamNames: string[]): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
teamNames.map((teamName) => this.enqueueTeam(teamName, 'startup_scan', 30_000))
|
||||
);
|
||||
}
|
||||
|
||||
noteTeamChange(event: TeamChangeEvent): void {
|
||||
if (event.type === 'lead-activity' && event.detail === 'offline') {
|
||||
this.queue.dropTeam(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'member-spawn') {
|
||||
const memberName = event.detail?.trim();
|
||||
if (memberName) {
|
||||
this.queue.enqueue({
|
||||
teamName: event.teamName,
|
||||
memberName,
|
||||
triggerReason: 'member_spawned',
|
||||
runAfterMs: 30_000,
|
||||
});
|
||||
} else {
|
||||
void this.enqueueTeam(event.teamName, 'member_spawned', 30_000).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'tool-activity') {
|
||||
const payload = parseToolActivity(event.detail);
|
||||
if (payload?.action === 'finish' && payload.memberName) {
|
||||
this.queue.enqueue({
|
||||
teamName: event.teamName,
|
||||
memberName: payload.memberName,
|
||||
triggerReason: 'tool_finished',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'member-turn-settled') {
|
||||
const payload = parseMemberTurnSettled(event.detail);
|
||||
const memberName = payload?.memberName?.trim();
|
||||
if (memberName) {
|
||||
this.queue.enqueue({
|
||||
teamName: event.teamName,
|
||||
memberName,
|
||||
triggerReason: 'turn_settled',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'inbox' || event.type === 'lead-message') {
|
||||
const recipient = parseInboxRecipient(event.detail);
|
||||
if (recipient) {
|
||||
this.queue.enqueue({
|
||||
teamName: event.teamName,
|
||||
memberName: recipient,
|
||||
triggerReason: 'inbox_changed',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const teamWideReason = TEAM_WIDE_REASONS[event.type];
|
||||
if (teamWideReason) {
|
||||
void this.enqueueTeam(event.teamName, teamWideReason).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async enqueueTeam(
|
||||
teamName: string,
|
||||
triggerReason: MemberWorkSyncTriggerReason,
|
||||
runAfterMs?: number
|
||||
): Promise<void> {
|
||||
const activeMembers = await this.rosterSource.loadActiveMemberNames(teamName);
|
||||
const materializer = this.materializer;
|
||||
if (materializer) {
|
||||
await Promise.allSettled(
|
||||
activeMembers.map((memberName) => materializer.materializeMember(teamName, memberName))
|
||||
);
|
||||
}
|
||||
for (const memberName of activeMembers) {
|
||||
this.queue.enqueue({ teamName, memberName, triggerReason, runAfterMs });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import {
|
||||
MEMBER_WORK_SYNC_GET_METRICS,
|
||||
MEMBER_WORK_SYNC_GET_STATUS,
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
type MemberWorkSyncMetricsRequest,
|
||||
type MemberWorkSyncReportRequest,
|
||||
type MemberWorkSyncReportResult,
|
||||
type MemberWorkSyncStatus,
|
||||
type MemberWorkSyncStatusRequest,
|
||||
type MemberWorkSyncTeamMetrics,
|
||||
} from '../../../contracts';
|
||||
|
||||
import type { MemberWorkSyncFeatureFacade } from '../../composition/createMemberWorkSyncFeature';
|
||||
import type { IpcMain } from 'electron';
|
||||
|
||||
const logger = createLogger('Feature:MemberWorkSync:IPC');
|
||||
|
||||
export function registerMemberWorkSyncIpc(
|
||||
ipcMain: IpcMain,
|
||||
feature: MemberWorkSyncFeatureFacade
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
MEMBER_WORK_SYNC_GET_STATUS,
|
||||
async (_event, request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> => {
|
||||
try {
|
||||
return await feature.getStatus(request);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get member work sync status', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
MEMBER_WORK_SYNC_GET_METRICS,
|
||||
async (_event, request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics> => {
|
||||
try {
|
||||
return await feature.getMetrics(request);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get member work sync metrics', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => {
|
||||
try {
|
||||
return await feature.report(request);
|
||||
} catch (error) {
|
||||
logger.error('Failed to submit member work sync report', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS);
|
||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS);
|
||||
ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { TeamInboxReader } from '@main/services/team/TeamInboxReader';
|
||||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
|
||||
import type { MemberWorkSyncInboxNudgePort } from '../../../core/application';
|
||||
|
||||
export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudgePort {
|
||||
constructor(
|
||||
private readonly inboxReader: Pick<TeamInboxReader, 'getMessagesFor'> = new TeamInboxReader(),
|
||||
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter()
|
||||
) {}
|
||||
|
||||
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
|
||||
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
|
||||
if (existing.some((message) => message.messageId === input.messageId)) {
|
||||
return { inserted: false, messageId: input.messageId };
|
||||
}
|
||||
|
||||
const result = await this.inboxWriter.sendMessage(input.teamName, {
|
||||
member: input.memberName,
|
||||
from: input.payload.from,
|
||||
to: input.payload.to,
|
||||
messageId: input.messageId,
|
||||
timestamp: input.timestamp,
|
||||
text: input.payload.text,
|
||||
taskRefs: input.payload.taskRefs,
|
||||
actionMode: input.payload.actionMode,
|
||||
summary: 'Work sync check',
|
||||
source: 'system_notification',
|
||||
messageKind: input.payload.messageKind,
|
||||
});
|
||||
|
||||
return {
|
||||
inserted: true,
|
||||
messageId: result.messageId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
||||
import {
|
||||
inferTeamProviderIdFromModel,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '@shared/utils/teamProvider';
|
||||
import path from 'path';
|
||||
|
||||
import { isReservedMemberName, normalizeMemberName } from '../../../core/domain';
|
||||
|
||||
import type {
|
||||
RuntimeTurnSettledTargetResolution,
|
||||
RuntimeTurnSettledTargetResolverPort,
|
||||
} from '../../../core/application';
|
||||
import type { RuntimeTurnSettledEvent } from '../../../core/domain';
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
||||
import type { TeamMember, TeamSummary } from '@shared/types';
|
||||
|
||||
export interface RuntimeTurnSettledTeamSource {
|
||||
listTeams(): Promise<TeamSummary[]>;
|
||||
getConfig(teamName: string): ReturnType<TeamConfigReader['getConfig']>;
|
||||
}
|
||||
|
||||
export interface AttributedMemberFileSource {
|
||||
listAttributedMemberFiles(
|
||||
teamName: string
|
||||
): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]>;
|
||||
}
|
||||
|
||||
export interface TeamRuntimeTurnSettledTargetResolverDeps {
|
||||
teamSource: RuntimeTurnSettledTeamSource;
|
||||
membersMetaStore: TeamMembersMetaStore;
|
||||
memberLogsFinder?: AttributedMemberFileSource;
|
||||
maxTeamsToScan?: number;
|
||||
}
|
||||
|
||||
function memberKey(member: Pick<TeamMember, 'name'>): string {
|
||||
return normalizeMemberName(member.name);
|
||||
}
|
||||
|
||||
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
|
||||
const byName = new Map<string, TeamMember>();
|
||||
for (const member of configMembers) {
|
||||
const key = memberKey(member);
|
||||
if (key) {
|
||||
byName.set(key, member);
|
||||
}
|
||||
}
|
||||
for (const member of metaMembers) {
|
||||
const key = memberKey(member);
|
||||
if (key) {
|
||||
byName.set(key, { ...byName.get(key), ...member });
|
||||
}
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function providerForMember(member: TeamMember | undefined): string | undefined {
|
||||
return (
|
||||
normalizeOptionalTeamProviderId(member?.providerId) ??
|
||||
inferTeamProviderIdFromModel(member?.model)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePath(value: string | undefined): string | null {
|
||||
if (!value?.trim()) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(value.trim());
|
||||
}
|
||||
|
||||
export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledTargetResolverPort {
|
||||
private readonly memberLogsFinder: AttributedMemberFileSource;
|
||||
private readonly maxTeamsToScan: number;
|
||||
|
||||
constructor(private readonly deps: TeamRuntimeTurnSettledTargetResolverDeps) {
|
||||
this.memberLogsFinder = deps.memberLogsFinder ?? new TeamMemberLogsFinder();
|
||||
this.maxTeamsToScan = Math.max(1, deps.maxTeamsToScan ?? 200);
|
||||
}
|
||||
|
||||
async resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution> {
|
||||
if (event.provider === 'codex') {
|
||||
return this.resolveProviderOwnedEvent(event, 'codex');
|
||||
}
|
||||
|
||||
if (event.provider === 'opencode') {
|
||||
return this.resolveProviderOwnedEvent(event, 'opencode');
|
||||
}
|
||||
|
||||
if (event.provider !== 'claude') {
|
||||
return { ok: false, reason: 'unsupported_provider' };
|
||||
}
|
||||
|
||||
const transcriptPath = normalizePath(event.transcriptPath);
|
||||
const sessionId = event.sessionId?.trim() || null;
|
||||
if (!transcriptPath && !sessionId) {
|
||||
return { ok: false, reason: 'missing_session_identity' };
|
||||
}
|
||||
|
||||
const teams = (await this.deps.teamSource.listTeams())
|
||||
.filter((team) => !team.deletedAt)
|
||||
.slice(0, this.maxTeamsToScan);
|
||||
|
||||
const candidates: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
exactPath: boolean;
|
||||
mtimeMs: number;
|
||||
}[] = [];
|
||||
|
||||
for (const team of teams) {
|
||||
const attributedFiles = await this.memberLogsFinder
|
||||
.listAttributedMemberFiles(team.teamName)
|
||||
.catch(() => []);
|
||||
for (const file of attributedFiles) {
|
||||
const exactPath = transcriptPath ? normalizePath(file.filePath) === transcriptPath : false;
|
||||
const sessionMatch = sessionId ? file.sessionId === sessionId : false;
|
||||
if (!exactPath && !sessionMatch) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
teamName: team.teamName,
|
||||
memberName: file.memberName,
|
||||
exactPath,
|
||||
mtimeMs: file.mtimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const candidate = candidates.sort((left, right) => {
|
||||
if (left.exactPath !== right.exactPath) {
|
||||
return left.exactPath ? -1 : 1;
|
||||
}
|
||||
return right.mtimeMs - left.mtimeMs;
|
||||
})[0];
|
||||
|
||||
if (!candidate) {
|
||||
return { ok: false, reason: 'no_matching_member_session' };
|
||||
}
|
||||
|
||||
const member = await this.resolveActiveMember(candidate.teamName, candidate.memberName);
|
||||
if (!member) {
|
||||
return { ok: false, reason: 'member_not_active' };
|
||||
}
|
||||
if (isReservedMemberName(member.name)) {
|
||||
return { ok: false, reason: 'reserved_member' };
|
||||
}
|
||||
|
||||
const providerId = providerForMember(member);
|
||||
if (providerId && providerId !== 'anthropic') {
|
||||
return { ok: false, reason: 'provider_mismatch' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
teamName: candidate.teamName,
|
||||
memberName: normalizeMemberName(member.name),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveProviderOwnedEvent(
|
||||
event: RuntimeTurnSettledEvent,
|
||||
expectedProviderId: 'codex' | 'opencode'
|
||||
): Promise<RuntimeTurnSettledTargetResolution> {
|
||||
const teamName = event.teamName?.trim();
|
||||
const memberName = event.memberName?.trim();
|
||||
if (!teamName || !memberName) {
|
||||
return { ok: false, reason: 'missing_team_member_identity' };
|
||||
}
|
||||
|
||||
const member = await this.resolveActiveMember(teamName, memberName);
|
||||
if (!member) {
|
||||
return { ok: false, reason: 'member_not_active' };
|
||||
}
|
||||
if (isReservedMemberName(member.name)) {
|
||||
return { ok: false, reason: 'reserved_member' };
|
||||
}
|
||||
|
||||
const providerId = providerForMember(member);
|
||||
if (providerId && providerId !== expectedProviderId) {
|
||||
return { ok: false, reason: 'provider_mismatch' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
teamName,
|
||||
memberName: normalizeMemberName(member.name),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveActiveMember(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<TeamMember | null> {
|
||||
const [config, metaMembers] = await Promise.all([
|
||||
this.deps.teamSource.getConfig(teamName),
|
||||
this.deps.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
if (!config || config.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizeMemberName(memberName);
|
||||
return (
|
||||
mergeMembers(config.members ?? [], metaMembers).find(
|
||||
(member) => !member.removedAt && memberKey(member) === normalizedTarget
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
inferTeamProviderIdFromModel,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '@shared/utils/teamProvider';
|
||||
|
||||
import {
|
||||
buildActionableWorkAgenda,
|
||||
isReservedMemberName,
|
||||
type MemberWorkSyncMemberLike,
|
||||
normalizeMemberName,
|
||||
} from '../../../core/domain';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncAgendaSourcePort,
|
||||
MemberWorkSyncAgendaSourceResult,
|
||||
MemberWorkSyncHashPort,
|
||||
} from '../../../core/application';
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
||||
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||
import type { TeamMember } from '@shared/types';
|
||||
|
||||
export interface TeamTaskAgendaSourceDeps {
|
||||
configReader: TeamConfigReader;
|
||||
taskReader: TeamTaskReader;
|
||||
kanbanManager: TeamKanbanManager;
|
||||
membersMetaStore: TeamMembersMetaStore;
|
||||
hash: MemberWorkSyncHashPort;
|
||||
clock: { now(): Date };
|
||||
}
|
||||
|
||||
function memberKey(member: Pick<TeamMember, 'name'>): string {
|
||||
return normalizeMemberName(member.name);
|
||||
}
|
||||
|
||||
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
|
||||
const byName = new Map<string, TeamMember>();
|
||||
for (const member of configMembers) {
|
||||
const key = memberKey(member);
|
||||
if (key) {
|
||||
byName.set(key, member);
|
||||
}
|
||||
}
|
||||
for (const member of metaMembers) {
|
||||
const key = memberKey(member);
|
||||
if (key) {
|
||||
byName.set(key, { ...byName.get(key), ...member });
|
||||
}
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function toMemberLike(member: TeamMember): MemberWorkSyncMemberLike {
|
||||
const providerId =
|
||||
normalizeOptionalTeamProviderId(member.providerId) ??
|
||||
inferTeamProviderIdFromModel(member.model);
|
||||
return {
|
||||
name: member.name,
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(member.model ? { model: member.model } : {}),
|
||||
...(member.agentType ? { agentType: member.agentType } : {}),
|
||||
...(member.removedAt ? { removedAt: String(member.removedAt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
|
||||
constructor(private readonly deps: TeamTaskAgendaSourceDeps) {}
|
||||
|
||||
async loadActiveMemberNames(teamName: string): Promise<string[]> {
|
||||
const config = await this.deps.configReader.getConfig(teamName);
|
||||
if (!config || config.deletedAt) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metaMembers = await this.deps.membersMetaStore.getMembers(teamName);
|
||||
return mergeMembers(config.members ?? [], metaMembers)
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => normalizeMemberName(member.name))
|
||||
.filter((memberName) => memberName.length > 0 && !isReservedMemberName(memberName))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async loadAgenda(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<MemberWorkSyncAgendaSourceResult> {
|
||||
const config = await this.deps.configReader.getConfig(input.teamName);
|
||||
if (!config || config.deletedAt) {
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
return {
|
||||
agenda: {
|
||||
teamName: input.teamName,
|
||||
memberName: normalizeMemberName(input.memberName),
|
||||
generatedAt: nowIso,
|
||||
items: [],
|
||||
diagnostics: config?.deletedAt ? ['team_deleted'] : ['team_config_missing'],
|
||||
},
|
||||
activeMemberNames: [],
|
||||
inactive: true,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [tasks, kanban, metaMembers] = await Promise.all([
|
||||
this.deps.taskReader.getTasks(input.teamName),
|
||||
this.deps.kanbanManager.getState(input.teamName),
|
||||
this.deps.membersMetaStore.getMembers(input.teamName),
|
||||
]);
|
||||
const members = mergeMembers(config.members ?? [], metaMembers);
|
||||
const activeMemberNames = members
|
||||
.filter((member) => !member.removedAt)
|
||||
.map((member) => normalizeMemberName(member.name))
|
||||
.filter(Boolean);
|
||||
const normalizedMemberName = normalizeMemberName(input.memberName);
|
||||
const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName);
|
||||
const providerId =
|
||||
normalizeOptionalTeamProviderId(member?.providerId) ??
|
||||
inferTeamProviderIdFromModel(member?.model);
|
||||
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
generatedAt: this.deps.clock.now().toISOString(),
|
||||
tasks,
|
||||
members: members.map(toMemberLike),
|
||||
kanbanReviewersByTaskId: Object.fromEntries(
|
||||
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])
|
||||
),
|
||||
hash: this.deps.hash.sha256Hex.bind(this.deps.hash),
|
||||
});
|
||||
|
||||
return {
|
||||
agenda,
|
||||
activeMemberNames,
|
||||
inactive: !activeMemberNames.includes(normalizedMemberName),
|
||||
...(providerId ? { providerId } : {}),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { MemberWorkSyncWatchdogCooldownPort } from '../../../core/application';
|
||||
|
||||
const DEFAULT_WATCHDOG_COOLDOWN_MS = 10 * 60_000;
|
||||
|
||||
interface StallJournalEntry {
|
||||
taskId: string;
|
||||
state: string;
|
||||
alertedAt?: string;
|
||||
}
|
||||
|
||||
function parseTime(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
export class TeamTaskStallJournalWorkSyncCooldown implements MemberWorkSyncWatchdogCooldownPort {
|
||||
constructor(
|
||||
private readonly teamsBasePath: string,
|
||||
private readonly cooldownMs: number = DEFAULT_WATCHDOG_COOLDOWN_MS
|
||||
) {}
|
||||
|
||||
async hasRecentNudge(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
taskIds: string[];
|
||||
nowIso: string;
|
||||
}): Promise<boolean> {
|
||||
const taskIds = new Set(input.taskIds);
|
||||
if (taskIds.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(
|
||||
join(this.teamsBasePath, input.teamName, 'stall-monitor-journal.json'),
|
||||
'utf8'
|
||||
);
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return true;
|
||||
}
|
||||
const now = parseTime(input.nowIso) ?? Date.now();
|
||||
return parsed.some((entry): boolean => {
|
||||
const row = entry as Partial<StallJournalEntry>;
|
||||
if (row.state !== 'alerted' || !row.taskId || !taskIds.has(row.taskId)) {
|
||||
return false;
|
||||
}
|
||||
const alertedAt = parseTime(row.alertedAt);
|
||||
return alertedAt != null && now - alertedAt <= this.cooldownMs;
|
||||
});
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== 'ENOENT';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
import {
|
||||
MemberWorkSyncDiagnosticsReader,
|
||||
MemberWorkSyncMetricsReader,
|
||||
MemberWorkSyncNudgeDispatcher,
|
||||
type MemberWorkSyncNudgeDispatchSummary,
|
||||
MemberWorkSyncPendingReportIntentReplayer,
|
||||
type MemberWorkSyncPendingReportReplaySummary,
|
||||
type MemberWorkSyncReconcileContext,
|
||||
MemberWorkSyncReconciler,
|
||||
MemberWorkSyncReporter,
|
||||
type RuntimeTurnSettledDrainSummary,
|
||||
RuntimeTurnSettledIngestor,
|
||||
type RuntimeTurnSettledTargetResolverPort,
|
||||
} from '../../core/application';
|
||||
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
|
||||
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
|
||||
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
|
||||
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
||||
import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown';
|
||||
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
||||
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
|
||||
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
|
||||
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
|
||||
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
||||
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
||||
import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore';
|
||||
import {
|
||||
MemberWorkSyncEventQueue,
|
||||
type MemberWorkSyncQueueDiagnostics,
|
||||
} from '../infrastructure/MemberWorkSyncEventQueue';
|
||||
import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWorkSyncNudgeDispatchScheduler';
|
||||
import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths';
|
||||
import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal';
|
||||
import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
|
||||
import { OpenCodeTurnSettledPayloadNormalizer } from '../infrastructure/OpenCodeTurnSettledPayloadNormalizer';
|
||||
import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler';
|
||||
import { RuntimeTurnSettledSpoolInitializer } from '../infrastructure/RuntimeTurnSettledSpoolInitializer';
|
||||
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncMetricsRequest,
|
||||
MemberWorkSyncReportRequest,
|
||||
MemberWorkSyncReportResult,
|
||||
MemberWorkSyncStatus,
|
||||
MemberWorkSyncStatusRequest,
|
||||
MemberWorkSyncTeamMetrics,
|
||||
} from '../../contracts';
|
||||
import type { MemberWorkSyncLoggerPort } from '../../core/application';
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
||||
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
export const MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV =
|
||||
'CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED';
|
||||
|
||||
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
||||
|
||||
function emptyNudgeDispatchSummary(): MemberWorkSyncNudgeDispatchSummary {
|
||||
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
|
||||
}
|
||||
|
||||
export function resolveMemberWorkSyncNudgeSideEffectsEnabled(
|
||||
env: Record<string, string | undefined> = process.env
|
||||
): boolean {
|
||||
const rawValue = env[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV];
|
||||
if (rawValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = rawValue.trim().toLowerCase();
|
||||
if (TRUE_ENV_VALUES.has(value)) {
|
||||
return true;
|
||||
}
|
||||
if (FALSE_ENV_VALUES.has(value)) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
|
||||
teamsBasePath: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, string> | null> {
|
||||
return new RuntimeTurnSettledSpoolInitializer(input.teamsBasePath).buildEnvironment({
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncFeatureFacade {
|
||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
noteTeamChange(event: TeamChangeEvent): void;
|
||||
enqueueStartupScan(teamNames: string[]): Promise<void>;
|
||||
replayPendingReports(teamNames: string[]): Promise<MemberWorkSyncPendingReportReplaySummary>;
|
||||
dispatchDueNudges(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
|
||||
buildRuntimeTurnSettledHookSettings(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, unknown> | null>;
|
||||
buildRuntimeTurnSettledEnvironment(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, string> | null>;
|
||||
drainRuntimeTurnSettledEvents(): Promise<RuntimeTurnSettledDrainSummary>;
|
||||
getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createMemberWorkSyncFeature(deps: {
|
||||
teamsBasePath: string;
|
||||
configReader: TeamConfigReader;
|
||||
taskReader: TeamTaskReader;
|
||||
kanbanManager: TeamKanbanManager;
|
||||
membersMetaStore: TeamMembersMetaStore;
|
||||
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
|
||||
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
||||
nudgeSideEffectsEnabled?: boolean;
|
||||
queueQuietWindowMs?: number;
|
||||
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}): MemberWorkSyncFeatureFacade {
|
||||
const clock = new SystemClockAdapter();
|
||||
const hash = new NodeHashAdapter();
|
||||
const agendaSource = new TeamTaskAgendaSource({
|
||||
configReader: deps.configReader,
|
||||
taskReader: deps.taskReader,
|
||||
kanbanManager: deps.kanbanManager,
|
||||
membersMetaStore: deps.membersMetaStore,
|
||||
hash,
|
||||
clock,
|
||||
});
|
||||
const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath);
|
||||
const auditJournal = new FileMemberWorkSyncAuditJournal(storePaths, deps.logger);
|
||||
const store = new JsonMemberWorkSyncStore(storePaths, {
|
||||
auditJournal,
|
||||
logger: deps.logger,
|
||||
});
|
||||
const runtimeTurnSettledSpool = new RuntimeTurnSettledSpoolInitializer(deps.teamsBasePath);
|
||||
const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({
|
||||
paths: runtimeTurnSettledSpool.getPaths(),
|
||||
});
|
||||
const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([
|
||||
new ClaudeStopHookPayloadNormalizer(hash),
|
||||
new CodexNativeTurnSettledPayloadNormalizer(hash),
|
||||
new OpenCodeTurnSettledPayloadNormalizer(hash),
|
||||
]);
|
||||
const runtimeTurnSettledTargetResolver =
|
||||
deps.runtimeTurnSettledTargetResolver ??
|
||||
new TeamRuntimeTurnSettledTargetResolver({
|
||||
teamSource: deps.configReader,
|
||||
membersMetaStore: deps.membersMetaStore,
|
||||
});
|
||||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
|
||||
const busySignal = new MemberWorkSyncToolActivityBusySignal();
|
||||
const nudgeSideEffectsEnabled =
|
||||
deps.nudgeSideEffectsEnabled ?? resolveMemberWorkSyncNudgeSideEffectsEnabled();
|
||||
const inboxNudge = nudgeSideEffectsEnabled ? new TeamInboxMemberWorkSyncNudgeSink() : null;
|
||||
const useCaseDeps = {
|
||||
clock,
|
||||
hash,
|
||||
agendaSource,
|
||||
statusStore: store,
|
||||
reportStore: store,
|
||||
...(nudgeSideEffectsEnabled ? { outboxStore: store } : {}),
|
||||
...(inboxNudge ? { inboxNudge } : {}),
|
||||
watchdogCooldown,
|
||||
busySignal,
|
||||
reportToken,
|
||||
auditJournal,
|
||||
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
||||
logger: deps.logger,
|
||||
};
|
||||
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
|
||||
const metricsReader = new MemberWorkSyncMetricsReader(useCaseDeps);
|
||||
const reporter = new MemberWorkSyncReporter(useCaseDeps);
|
||||
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
|
||||
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
|
||||
const nudgeDispatcher = new MemberWorkSyncNudgeDispatcher(useCaseDeps);
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
||||
await reconciler.execute(request, context);
|
||||
if (nudgeSideEffectsEnabled) {
|
||||
await nudgeDispatcher.dispatchDue({
|
||||
teamNames: [request.teamName],
|
||||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
isTeamActive: deps.isTeamActive ?? (() => true),
|
||||
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
|
||||
auditJournal,
|
||||
logger: deps.logger,
|
||||
});
|
||||
const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue, {
|
||||
materializeMember: (teamName, memberName) =>
|
||||
storePaths.ensureMemberWorkSyncDir(teamName, memberName),
|
||||
});
|
||||
const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({
|
||||
eventStore: runtimeTurnSettledStore,
|
||||
normalizer: runtimeTurnSettledNormalizer,
|
||||
targetResolver: runtimeTurnSettledTargetResolver,
|
||||
reconcileQueue: {
|
||||
enqueueRuntimeTurnSettled: ({ teamName, memberName, event }) => {
|
||||
router.noteTeamChange({
|
||||
type: 'member-turn-settled',
|
||||
teamName,
|
||||
detail: JSON.stringify({
|
||||
memberName,
|
||||
sourceId: event.sourceId,
|
||||
provider: event.provider,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
clock,
|
||||
auditJournal,
|
||||
logger: deps.logger,
|
||||
});
|
||||
const runtimeTurnSettledDrainScheduler = new RuntimeTurnSettledDrainScheduler({
|
||||
drain: () => runtimeTurnSettledIngestor.drainPending(),
|
||||
logger: deps.logger,
|
||||
});
|
||||
const nudgeDispatchScheduler =
|
||||
nudgeSideEffectsEnabled && deps.listLifecycleActiveTeamNames
|
||||
? new MemberWorkSyncNudgeDispatchScheduler({
|
||||
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
|
||||
dispatchDue: (teamNames) =>
|
||||
nudgeDispatcher.dispatchDue({
|
||||
teamNames,
|
||||
claimedBy: `member-work-sync:${process.pid}:scheduled`,
|
||||
}),
|
||||
logger: deps.logger,
|
||||
})
|
||||
: null;
|
||||
runtimeTurnSettledDrainScheduler.start();
|
||||
nudgeDispatchScheduler?.start();
|
||||
|
||||
return {
|
||||
getStatus: (request) => diagnosticsReader.execute(request),
|
||||
getMetrics: (request) => metricsReader.execute(request),
|
||||
report: (request) => reporter.execute(request),
|
||||
noteTeamChange: (event) => {
|
||||
busySignal.noteTeamChange(event);
|
||||
router.noteTeamChange(event);
|
||||
},
|
||||
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),
|
||||
replayPendingReports: async (teamNames) => {
|
||||
const summaries = await Promise.allSettled(
|
||||
teamNames.map((teamName) => pendingReportReplayer.replayTeam(teamName))
|
||||
);
|
||||
return summaries.reduce<MemberWorkSyncPendingReportReplaySummary>(
|
||||
(accumulator, summary) => {
|
||||
if (summary.status !== 'fulfilled') {
|
||||
return accumulator;
|
||||
}
|
||||
accumulator.processed += summary.value.processed;
|
||||
accumulator.accepted += summary.value.accepted;
|
||||
accumulator.rejected += summary.value.rejected;
|
||||
accumulator.superseded += summary.value.superseded;
|
||||
return accumulator;
|
||||
},
|
||||
{ processed: 0, accepted: 0, rejected: 0, superseded: 0 }
|
||||
);
|
||||
},
|
||||
dispatchDueNudges: (teamNames) =>
|
||||
nudgeSideEffectsEnabled
|
||||
? nudgeDispatcher.dispatchDue({
|
||||
teamNames,
|
||||
claimedBy: `member-work-sync:${process.pid}`,
|
||||
})
|
||||
: Promise.resolve(emptyNudgeDispatchSummary()),
|
||||
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
|
||||
runtimeTurnSettledSpool.buildHookSettings({ provider }),
|
||||
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>
|
||||
runtimeTurnSettledSpool.buildEnvironment({ provider }),
|
||||
drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(),
|
||||
getQueueDiagnostics: () => queue.getDiagnostics(),
|
||||
dispose: async () => {
|
||||
runtimeTurnSettledDrainScheduler.dispose();
|
||||
await Promise.allSettled([queue.stop(), nudgeDispatchScheduler?.dispose()]);
|
||||
},
|
||||
};
|
||||
}
|
||||
12
src/features/member-work-sync/main/index.ts
Normal file
12
src/features/member-work-sync/main/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export type { RuntimeTurnSettledProvider } from '../core/domain';
|
||||
export {
|
||||
registerMemberWorkSyncIpc,
|
||||
removeMemberWorkSyncIpc,
|
||||
} from './adapters/input/registerMemberWorkSyncIpc';
|
||||
export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature';
|
||||
export {
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
|
||||
resolveMemberWorkSyncNudgeSideEffectsEnabled,
|
||||
} from './composition/createMemberWorkSyncFeature';
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
buildRuntimeTurnSettledSourceId,
|
||||
type RuntimeTurnSettledProvider,
|
||||
} from '../../core/domain';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncHashPort,
|
||||
RuntimeTurnSettledPayloadNormalization,
|
||||
RuntimeTurnSettledPayloadNormalizerPort,
|
||||
} from '../../core/application';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class ClaudeStopHookPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort {
|
||||
constructor(private readonly hash: MemberWorkSyncHashPort) {}
|
||||
|
||||
normalize(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
recordedAt: string;
|
||||
}): RuntimeTurnSettledPayloadNormalization {
|
||||
if (input.provider !== 'claude') {
|
||||
return { ok: false, reason: 'unsupported_provider' };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.raw);
|
||||
} catch {
|
||||
return { ok: false, reason: 'invalid_json' };
|
||||
}
|
||||
|
||||
const payload = asRecord(parsed);
|
||||
if (!payload) {
|
||||
return { ok: false, reason: 'payload_not_object' };
|
||||
}
|
||||
|
||||
const hookEventName = getString(payload, 'hook_event_name', 'hookEventName');
|
||||
if (hookEventName !== 'Stop') {
|
||||
return { ok: false, reason: 'not_stop_hook' };
|
||||
}
|
||||
|
||||
const payloadHash = this.hash.sha256Hex(input.raw);
|
||||
const event = {
|
||||
schemaVersion: 1 as const,
|
||||
provider: 'claude' as const,
|
||||
hookEventName: 'Stop' as const,
|
||||
payloadHash,
|
||||
recordedAt: input.recordedAt,
|
||||
sourceId: buildRuntimeTurnSettledSourceId({
|
||||
provider: 'claude',
|
||||
sessionId: getString(payload, 'session_id', 'sessionId'),
|
||||
turnId: getString(payload, 'turn_id', 'turnId'),
|
||||
transcriptPath: getString(payload, 'transcript_path', 'transcriptPath'),
|
||||
payloadHash,
|
||||
}),
|
||||
sessionId: getString(payload, 'session_id', 'sessionId'),
|
||||
turnId: getString(payload, 'turn_id', 'turnId'),
|
||||
transcriptPath: getString(payload, 'transcript_path', 'transcriptPath'),
|
||||
cwd: getString(payload, 'cwd'),
|
||||
};
|
||||
|
||||
if (!event.sessionId && !event.transcriptPath) {
|
||||
return { ok: false, reason: 'missing_session_identity' };
|
||||
}
|
||||
|
||||
return { ok: true, event };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import {
|
||||
buildRuntimeTurnSettledSourceId,
|
||||
type RuntimeTurnSettledProvider,
|
||||
} from '../../core/domain';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncHashPort,
|
||||
RuntimeTurnSettledPayloadNormalization,
|
||||
RuntimeTurnSettledPayloadNormalizerPort,
|
||||
} from '../../core/application';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class CodexNativeTurnSettledPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort {
|
||||
constructor(private readonly hash: MemberWorkSyncHashPort) {}
|
||||
|
||||
normalize(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
recordedAt: string;
|
||||
}): RuntimeTurnSettledPayloadNormalization {
|
||||
if (input.provider !== 'codex') {
|
||||
return { ok: false, reason: 'unsupported_provider' };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.raw);
|
||||
} catch {
|
||||
return { ok: false, reason: 'invalid_json' };
|
||||
}
|
||||
|
||||
const payload = asRecord(parsed);
|
||||
if (!payload) {
|
||||
return { ok: false, reason: 'payload_not_object' };
|
||||
}
|
||||
|
||||
const provider = getString(payload, 'provider');
|
||||
if (provider !== 'codex') {
|
||||
return { ok: false, reason: 'provider_mismatch' };
|
||||
}
|
||||
const source = getString(payload, 'source');
|
||||
if (source !== 'agent-teams-orchestrator-codex-native') {
|
||||
return { ok: false, reason: 'source_mismatch' };
|
||||
}
|
||||
|
||||
const eventName = getString(payload, 'eventName', 'event_name');
|
||||
const hookEventName = getString(payload, 'hookEventName', 'hook_event_name');
|
||||
if (eventName !== 'runtime_turn_settled' && hookEventName !== 'Stop') {
|
||||
return { ok: false, reason: 'not_turn_settled_event' };
|
||||
}
|
||||
|
||||
const sessionId = getString(payload, 'sessionId', 'session_id');
|
||||
const teamName = getString(payload, 'teamName', 'team_name');
|
||||
const memberName = getString(payload, 'memberName', 'member_name', 'agentName', 'agent_name');
|
||||
if (!sessionId) {
|
||||
return { ok: false, reason: 'missing_session_identity' };
|
||||
}
|
||||
if (!teamName || !memberName) {
|
||||
return { ok: false, reason: 'missing_team_member_identity' };
|
||||
}
|
||||
|
||||
const payloadHash = this.hash.sha256Hex(input.raw);
|
||||
const threadId = getString(payload, 'threadId', 'thread_id');
|
||||
const turnId = getString(payload, 'turnId', 'turn_id') ?? threadId;
|
||||
const cwd = getString(payload, 'cwd');
|
||||
const agentId = getString(payload, 'agentId', 'agent_id');
|
||||
const outcome = getString(payload, 'outcome');
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
schemaVersion: 1,
|
||||
provider: 'codex',
|
||||
hookEventName: 'Stop',
|
||||
payloadHash,
|
||||
recordedAt: getString(payload, 'recordedAt', 'recorded_at') ?? input.recordedAt,
|
||||
sourceId: buildRuntimeTurnSettledSourceId({
|
||||
provider: 'codex',
|
||||
sessionId,
|
||||
turnId,
|
||||
payloadHash,
|
||||
}),
|
||||
sessionId,
|
||||
...(turnId ? { turnId } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
teamName,
|
||||
memberName,
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(outcome ? { outcome } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type {
|
||||
RuntimeTurnSettledPayloadNormalization,
|
||||
RuntimeTurnSettledPayloadNormalizerPort,
|
||||
} from '../../core/application';
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
export class CompositeRuntimeTurnSettledPayloadNormalizer
|
||||
implements RuntimeTurnSettledPayloadNormalizerPort
|
||||
{
|
||||
constructor(
|
||||
private readonly normalizers: readonly RuntimeTurnSettledPayloadNormalizerPort[]
|
||||
) {}
|
||||
|
||||
normalize(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
recordedAt: string;
|
||||
}): RuntimeTurnSettledPayloadNormalization {
|
||||
let lastUnsupportedReason = 'unsupported_provider';
|
||||
for (const normalizer of this.normalizers) {
|
||||
const result = normalizer.normalize(input);
|
||||
if (result.ok) {
|
||||
return result;
|
||||
}
|
||||
if (result.reason !== 'unsupported_provider') {
|
||||
return result;
|
||||
}
|
||||
lastUnsupportedReason = result.reason;
|
||||
}
|
||||
return { ok: false, reason: lastUnsupportedReason };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import { withFileLock } from '@main/services/team/fileLock';
|
||||
import { appendFile, mkdir, rename, rm, stat } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncAuditEvent,
|
||||
MemberWorkSyncAuditJournalPort,
|
||||
MemberWorkSyncLoggerPort,
|
||||
} from '../../core/application';
|
||||
import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths';
|
||||
|
||||
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_ROTATED_FILE_COUNT = 5;
|
||||
const MAX_PREVIEW_CHARS = 240;
|
||||
const MAX_DIAGNOSTICS = 20;
|
||||
const MAX_TRIGGER_REASONS = 20;
|
||||
const MAX_TASK_REFS = 20;
|
||||
const MAX_SHORT_FIELD_CHARS = 240;
|
||||
|
||||
interface PersistedAuditEvent extends MemberWorkSyncAuditEvent {
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface FileMemberWorkSyncAuditJournalOptions {
|
||||
maxBytes?: number;
|
||||
rotatedFileCount?: number;
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
|
||||
}
|
||||
|
||||
function sanitizeMetadata(
|
||||
metadata: MemberWorkSyncAuditEvent['metadata']
|
||||
): MemberWorkSyncAuditEvent['metadata'] {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
const sanitized = Object.create(null) as NonNullable<MemberWorkSyncAuditEvent['metadata']>;
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
sanitized[truncateText(key, MAX_SHORT_FIELD_CHARS)] =
|
||||
typeof value === 'string' ? truncateText(value, MAX_SHORT_FIELD_CHARS) : value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function sanitizeTaskRefs(
|
||||
taskRefs: MemberWorkSyncAuditEvent['taskRefs']
|
||||
): MemberWorkSyncAuditEvent['taskRefs'] {
|
||||
return taskRefs?.slice(0, MAX_TASK_REFS).map((taskRef) => ({
|
||||
taskId: truncateText(taskRef.taskId, MAX_SHORT_FIELD_CHARS),
|
||||
...(taskRef.displayId
|
||||
? { displayId: truncateText(taskRef.displayId, MAX_SHORT_FIELD_CHARS) }
|
||||
: {}),
|
||||
...(taskRef.teamName
|
||||
? { teamName: truncateText(taskRef.teamName, MAX_SHORT_FIELD_CHARS) }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function sanitizeEvent(event: MemberWorkSyncAuditEvent): PersistedAuditEvent {
|
||||
return {
|
||||
...event,
|
||||
schemaVersion: 1,
|
||||
source: truncateText(event.source, MAX_SHORT_FIELD_CHARS),
|
||||
...(event.reason ? { reason: truncateText(event.reason, MAX_SHORT_FIELD_CHARS) } : {}),
|
||||
...(event.providerId
|
||||
? { providerId: truncateText(event.providerId, MAX_SHORT_FIELD_CHARS) }
|
||||
: {}),
|
||||
...(event.state ? { state: truncateText(event.state, MAX_SHORT_FIELD_CHARS) } : {}),
|
||||
...(event.agendaFingerprint
|
||||
? { agendaFingerprint: truncateText(event.agendaFingerprint, MAX_SHORT_FIELD_CHARS) }
|
||||
: {}),
|
||||
...(typeof event.messagePreview === 'string'
|
||||
? { messagePreview: truncateText(event.messagePreview, MAX_PREVIEW_CHARS) }
|
||||
: {}),
|
||||
...(event.diagnostics
|
||||
? {
|
||||
diagnostics: event.diagnostics
|
||||
.slice(0, MAX_DIAGNOSTICS)
|
||||
.map((diagnostic) => truncateText(diagnostic, MAX_SHORT_FIELD_CHARS)),
|
||||
}
|
||||
: {}),
|
||||
...(event.triggerReasons
|
||||
? {
|
||||
triggerReasons: event.triggerReasons
|
||||
.slice(0, MAX_TRIGGER_REASONS)
|
||||
.map((reason) => truncateText(reason, MAX_SHORT_FIELD_CHARS)),
|
||||
}
|
||||
: {}),
|
||||
...(event.taskRefs ? { taskRefs: sanitizeTaskRefs(event.taskRefs) } : {}),
|
||||
...(event.metadata ? { metadata: sanitizeMetadata(event.metadata) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function rotatedPath(filePath: string, index: number): string {
|
||||
return `${filePath}.${index}`;
|
||||
}
|
||||
|
||||
async function rotateIfNeeded(
|
||||
filePath: string,
|
||||
maxBytes: number,
|
||||
rotatedFileCount: number
|
||||
): Promise<void> {
|
||||
const current = await stat(filePath).catch(() => null);
|
||||
if (!current?.isFile() || current.size < maxBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await rm(rotatedPath(filePath, rotatedFileCount), { force: true }).catch(() => undefined);
|
||||
for (let index = rotatedFileCount - 1; index >= 1; index -= 1) {
|
||||
await rename(rotatedPath(filePath, index), rotatedPath(filePath, index + 1)).catch(
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
await rename(filePath, rotatedPath(filePath, 1)).catch(() => undefined);
|
||||
}
|
||||
|
||||
export class NoopMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort {
|
||||
async append(): Promise<void> {
|
||||
// Intentionally empty.
|
||||
}
|
||||
}
|
||||
|
||||
export class FileMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort {
|
||||
private readonly maxBytes: number;
|
||||
private readonly rotatedFileCount: number;
|
||||
private readonly appendChains = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly paths: MemberWorkSyncStorePaths,
|
||||
private readonly logger?: MemberWorkSyncLoggerPort,
|
||||
options: FileMemberWorkSyncAuditJournalOptions = {}
|
||||
) {
|
||||
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
this.rotatedFileCount = options.rotatedFileCount ?? DEFAULT_ROTATED_FILE_COUNT;
|
||||
}
|
||||
|
||||
async append(event: MemberWorkSyncAuditEvent): Promise<void> {
|
||||
const filePath = this.paths.getMemberJournalPath(event.teamName, event.memberName);
|
||||
const previous = this.appendChains.get(filePath) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(() => this.appendToFile(filePath, event));
|
||||
|
||||
this.appendChains.set(filePath, next);
|
||||
|
||||
try {
|
||||
await next;
|
||||
} finally {
|
||||
if (this.appendChains.get(filePath) === next) {
|
||||
this.appendChains.delete(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async appendToFile(filePath: string, event: MemberWorkSyncAuditEvent): Promise<void> {
|
||||
try {
|
||||
await this.paths.ensureMemberWorkSyncDir(event.teamName, event.memberName);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await withFileLock(filePath, async () => {
|
||||
await rotateIfNeeded(filePath, this.maxBytes, this.rotatedFileCount);
|
||||
await appendFile(filePath, `${JSON.stringify(sanitizeEvent(event))}\n`, 'utf8');
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger?.warn('member work sync audit journal append failed', {
|
||||
teamName: event.teamName,
|
||||
memberName: event.memberName,
|
||||
event: event.event,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { isRuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
import type {
|
||||
RuntimeTurnSettledClaimedPayload,
|
||||
RuntimeTurnSettledEventStorePort,
|
||||
RuntimeTurnSettledInvalidResult,
|
||||
RuntimeTurnSettledProcessedResult,
|
||||
} from '../../core/application';
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
||||
|
||||
const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
|
||||
const DEFAULT_PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_PROCESSED_RETENTION_COUNT = 1000;
|
||||
const DEFAULT_PROCESSING_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface FileRuntimeTurnSettledEventStoreDeps {
|
||||
paths: RuntimeTurnSettledSpoolPaths;
|
||||
maxPayloadBytes?: number;
|
||||
processedRetentionMs?: number;
|
||||
processedRetentionCount?: number;
|
||||
processingStaleMs?: number;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
function parseProviderFromFileName(fileName: string): RuntimeTurnSettledProvider | null {
|
||||
const parts = fileName.split('.');
|
||||
const provider = parts.length >= 3 ? parts[parts.length - 2] : null;
|
||||
return isRuntimeTurnSettledProvider(provider) ? provider : null;
|
||||
}
|
||||
|
||||
function buildMetaFilePath(filePath: string): string {
|
||||
return `${filePath}.meta.json`;
|
||||
}
|
||||
|
||||
async function moveFileBestEffort(sourcePath: string, targetPath: string): Promise<void> {
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
try {
|
||||
await rename(sourcePath, targetPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEventStorePort {
|
||||
private readonly maxPayloadBytes: number;
|
||||
private readonly processedRetentionMs: number;
|
||||
private readonly processedRetentionCount: number;
|
||||
private readonly processingStaleMs: number;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(private readonly deps: FileRuntimeTurnSettledEventStoreDeps) {
|
||||
this.maxPayloadBytes = deps.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
|
||||
this.processedRetentionMs = deps.processedRetentionMs ?? DEFAULT_PROCESSED_RETENTION_MS;
|
||||
this.processedRetentionCount =
|
||||
deps.processedRetentionCount ?? DEFAULT_PROCESSED_RETENTION_COUNT;
|
||||
this.processingStaleMs = deps.processingStaleMs ?? DEFAULT_PROCESSING_STALE_MS;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async claimPending(limit: number): Promise<RuntimeTurnSettledClaimedPayload[]> {
|
||||
await this.ensureDirectories();
|
||||
await this.recoverStaleProcessingPayloads();
|
||||
|
||||
const entries = await readdir(this.deps.paths.getIncomingDir(), { withFileTypes: true }).catch(
|
||||
() => []
|
||||
);
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((fileName) => !fileName.startsWith('.'))
|
||||
.sort()
|
||||
.slice(0, Math.max(0, limit));
|
||||
|
||||
const claimed: RuntimeTurnSettledClaimedPayload[] = [];
|
||||
for (const fileName of files) {
|
||||
const provider = parseProviderFromFileName(fileName);
|
||||
const incomingPath = path.join(this.deps.paths.getIncomingDir(), fileName);
|
||||
if (!provider) {
|
||||
await this.quarantineIncoming(incomingPath, fileName, 'unsupported_provider');
|
||||
continue;
|
||||
}
|
||||
|
||||
const processingPath = path.join(this.deps.paths.getProcessingDir(), fileName);
|
||||
try {
|
||||
await rename(incomingPath, processingPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileStat = await stat(processingPath).catch(() => null);
|
||||
const payloadTooLarge = Boolean(fileStat?.isFile() && fileStat.size > this.maxPayloadBytes);
|
||||
if (!fileStat?.isFile() || payloadTooLarge) {
|
||||
await this.markInvalid(
|
||||
{
|
||||
id: fileName,
|
||||
filePath: processingPath,
|
||||
fileName,
|
||||
provider,
|
||||
raw: '',
|
||||
claimedAt: this.now().toISOString(),
|
||||
},
|
||||
{
|
||||
reason: payloadTooLarge ? 'payload_too_large' : 'payload_missing',
|
||||
processedAt: this.now().toISOString(),
|
||||
}
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = await readFile(processingPath, 'utf8').catch(() => '');
|
||||
claimed.push({
|
||||
id: fileName,
|
||||
filePath: processingPath,
|
||||
fileName,
|
||||
provider,
|
||||
raw,
|
||||
claimedAt: this.now().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return claimed;
|
||||
}
|
||||
|
||||
async markProcessed(
|
||||
payload: RuntimeTurnSettledClaimedPayload,
|
||||
result: RuntimeTurnSettledProcessedResult
|
||||
): Promise<void> {
|
||||
const processedPath = path.join(this.deps.paths.getProcessedDir(), payload.fileName);
|
||||
await moveFileBestEffort(payload.filePath, processedPath);
|
||||
await writeFile(
|
||||
buildMetaFilePath(processedPath),
|
||||
`${JSON.stringify(result, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
await this.cleanupDirectory(this.deps.paths.getProcessedDir());
|
||||
}
|
||||
|
||||
async markInvalid(
|
||||
payload: RuntimeTurnSettledClaimedPayload,
|
||||
result: RuntimeTurnSettledInvalidResult
|
||||
): Promise<void> {
|
||||
const invalidPath = path.join(this.deps.paths.getInvalidDir(), payload.fileName);
|
||||
await moveFileBestEffort(payload.filePath, invalidPath);
|
||||
await writeFile(buildMetaFilePath(invalidPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
await this.cleanupDirectory(this.deps.paths.getInvalidDir());
|
||||
}
|
||||
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
await Promise.all([
|
||||
mkdir(this.deps.paths.getIncomingDir(), { recursive: true }),
|
||||
mkdir(this.deps.paths.getProcessingDir(), { recursive: true }),
|
||||
mkdir(this.deps.paths.getProcessedDir(), { recursive: true }),
|
||||
mkdir(this.deps.paths.getInvalidDir(), { recursive: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
private async recoverStaleProcessingPayloads(): Promise<void> {
|
||||
const cutoff = this.now().getTime() - this.processingStaleMs;
|
||||
const entries = await readdir(this.deps.paths.getProcessingDir(), {
|
||||
withFileTypes: true,
|
||||
}).catch(() => []);
|
||||
|
||||
await Promise.allSettled(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() && !entry.name.startsWith('.') && !entry.name.endsWith('.meta.json')
|
||||
)
|
||||
.map(async (entry) => {
|
||||
const processingPath = path.join(this.deps.paths.getProcessingDir(), entry.name);
|
||||
const fileStat = await stat(processingPath).catch(() => null);
|
||||
if (!fileStat?.isFile() || fileStat.mtimeMs > cutoff) {
|
||||
return;
|
||||
}
|
||||
|
||||
await moveFileBestEffort(
|
||||
processingPath,
|
||||
path.join(this.deps.paths.getIncomingDir(), entry.name)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async quarantineIncoming(
|
||||
incomingPath: string,
|
||||
fileName: string,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const invalidPath = path.join(this.deps.paths.getInvalidDir(), fileName);
|
||||
await moveFileBestEffort(incomingPath, invalidPath);
|
||||
await writeFile(
|
||||
buildMetaFilePath(invalidPath),
|
||||
`${JSON.stringify({ reason, processedAt: this.now().toISOString() }, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
private async cleanupDirectory(directory: string): Promise<void> {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
const files = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && !entry.name.endsWith('.meta.json'))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(directory, entry.name);
|
||||
const fileStat = await stat(filePath).catch(() => null);
|
||||
return fileStat?.isFile() ? { filePath, mtimeMs: fileStat.mtimeMs } : null;
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter((entry): entry is { filePath: string; mtimeMs: number } => Boolean(entry))
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
|
||||
const cutoff = this.now().getTime() - this.processedRetentionMs;
|
||||
const toRemove = files.filter(
|
||||
(file, index) => index >= this.processedRetentionCount || file.mtimeMs < cutoff
|
||||
);
|
||||
|
||||
await Promise.allSettled(
|
||||
toRemove.flatMap((file) => [
|
||||
rm(file.filePath, { force: true }),
|
||||
rm(buildMetaFilePath(file.filePath), { force: true }),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
|
||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncReportTokenCreateInput,
|
||||
MemberWorkSyncReportTokenPort,
|
||||
MemberWorkSyncReportTokenVerification,
|
||||
MemberWorkSyncReportTokenVerifyInput,
|
||||
} from '../../core/application';
|
||||
import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths';
|
||||
|
||||
const TOKEN_PREFIX = 'wrs:v1';
|
||||
const TOKEN_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
interface SecretFile {
|
||||
schemaVersion: 1;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
version: 1;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
agendaFingerprint: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): string {
|
||||
return Buffer.from(value, 'base64url').toString('utf8');
|
||||
}
|
||||
|
||||
function isSecretFile(value: unknown): value is SecretFile {
|
||||
return (
|
||||
value != null &&
|
||||
typeof value === 'object' &&
|
||||
(value as SecretFile).schemaVersion === 1 &&
|
||||
typeof (value as SecretFile).secret === 'string' &&
|
||||
(value as SecretFile).secret.length >= 32
|
||||
);
|
||||
}
|
||||
|
||||
function isTokenPayload(value: unknown): value is TokenPayload {
|
||||
return (
|
||||
value != null &&
|
||||
typeof value === 'object' &&
|
||||
(value as TokenPayload).version === 1 &&
|
||||
typeof (value as TokenPayload).teamName === 'string' &&
|
||||
typeof (value as TokenPayload).memberName === 'string' &&
|
||||
typeof (value as TokenPayload).agendaFingerprint === 'string' &&
|
||||
typeof (value as TokenPayload).expiresAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
const leftBytes = Buffer.from(left);
|
||||
const rightBytes = Buffer.from(right);
|
||||
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
||||
}
|
||||
|
||||
export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncReportTokenPort {
|
||||
private readonly secretCache = new Map<string, Promise<string>>();
|
||||
|
||||
constructor(private readonly paths: MemberWorkSyncStorePaths) {}
|
||||
|
||||
async create(input: MemberWorkSyncReportTokenCreateInput): Promise<{
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}> {
|
||||
const expiresAt = new Date(Date.parse(input.issuedAt) + TOKEN_TTL_MS).toISOString();
|
||||
const payload: TokenPayload = {
|
||||
version: 1,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
agendaFingerprint: input.agendaFingerprint,
|
||||
expiresAt,
|
||||
};
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signature = await this.sign(input.teamName, encodedPayload);
|
||||
return {
|
||||
token: `${TOKEN_PREFIX}.${encodedPayload}.${signature}`,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async verify(
|
||||
input: MemberWorkSyncReportTokenVerifyInput
|
||||
): Promise<MemberWorkSyncReportTokenVerification> {
|
||||
if (!input.token) {
|
||||
return { ok: false, reason: 'missing' };
|
||||
}
|
||||
|
||||
const [prefix, encodedPayload, signature, extra] = input.token.split('.');
|
||||
if (prefix !== TOKEN_PREFIX || !encodedPayload || !signature || extra) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
const expectedSignature = await this.sign(input.teamName, encodedPayload);
|
||||
if (!safeEqual(signature, expectedSignature)) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(base64UrlDecode(encodedPayload));
|
||||
} catch {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
if (!isTokenPayload(payload)) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
if (
|
||||
payload.teamName !== input.teamName ||
|
||||
payload.memberName !== input.memberName ||
|
||||
payload.agendaFingerprint !== input.agendaFingerprint
|
||||
) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
if (Date.parse(payload.expiresAt) <= Date.parse(input.nowIso)) {
|
||||
return { ok: false, reason: 'expired' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async sign(teamName: string, encodedPayload: string): Promise<string> {
|
||||
const secret = await this.getSecret(teamName);
|
||||
return createHmac('sha256', secret).update(encodedPayload).digest('base64url');
|
||||
}
|
||||
|
||||
private async getSecret(teamName: string): Promise<string> {
|
||||
const existing = this.secretCache.get(teamName);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const next = this.loadOrCreateSecret(teamName);
|
||||
this.secretCache.set(teamName, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private async loadOrCreateSecret(teamName: string): Promise<string> {
|
||||
try {
|
||||
const raw = await readFile(this.paths.getReportTokenSecretPath(teamName), 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (isSecretFile(parsed)) {
|
||||
return parsed.secret;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const secretFile: SecretFile = {
|
||||
schemaVersion: 1,
|
||||
secret: randomBytes(32).toString('base64url'),
|
||||
};
|
||||
await mkdir(this.paths.getTeamDir(teamName), { recursive: true });
|
||||
await atomicWriteAsync(
|
||||
this.paths.getReportTokenSecretPath(teamName),
|
||||
JSON.stringify(secretFile, null, 2)
|
||||
);
|
||||
return secretFile.secret;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,309 @@
|
|||
import type {
|
||||
MemberWorkSyncAuditEvent,
|
||||
MemberWorkSyncAuditJournalPort,
|
||||
MemberWorkSyncLoggerPort,
|
||||
} from '../../core/application';
|
||||
import type { MemberWorkSyncReconcileContext } from '../../core/application/MemberWorkSyncReconciler';
|
||||
|
||||
export type MemberWorkSyncTriggerReason =
|
||||
| 'startup_scan'
|
||||
| 'config_changed'
|
||||
| 'task_changed'
|
||||
| 'inbox_changed'
|
||||
| 'member_spawned'
|
||||
| 'tool_finished'
|
||||
| 'runtime_activity'
|
||||
| 'turn_settled'
|
||||
| 'manual_refresh';
|
||||
|
||||
export interface MemberWorkSyncQueueDiagnostics {
|
||||
queued: number;
|
||||
running: number;
|
||||
enqueued: number;
|
||||
coalesced: number;
|
||||
reconciled: number;
|
||||
dropped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
runAt: number;
|
||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||
}
|
||||
|
||||
interface RunningItem {
|
||||
rerunRequested: boolean;
|
||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncEventQueueDeps {
|
||||
reconcile(
|
||||
input: { teamName: string; memberName: string },
|
||||
context: MemberWorkSyncReconcileContext
|
||||
): Promise<void>;
|
||||
isTeamActive(teamName: string): Promise<boolean> | boolean;
|
||||
quietWindowMs?: number;
|
||||
concurrency?: number;
|
||||
now?: () => number;
|
||||
nowIso?: () => string;
|
||||
auditJournal?: MemberWorkSyncAuditJournalPort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
function keyOf(teamName: string, memberName: string): string {
|
||||
return `${teamName}\0${memberName.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export class MemberWorkSyncEventQueue {
|
||||
private readonly items = new Map<string, QueueItem>();
|
||||
private readonly running = new Map<string, RunningItem>();
|
||||
private readonly inFlight = new Set<Promise<void>>();
|
||||
private readonly quietWindowMs: number;
|
||||
private readonly concurrency: number;
|
||||
private readonly now: () => number;
|
||||
private readonly nowIso: () => string;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stopped = false;
|
||||
private counters = {
|
||||
enqueued: 0,
|
||||
coalesced: 0,
|
||||
reconciled: 0,
|
||||
dropped: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
constructor(private readonly deps: MemberWorkSyncEventQueueDeps) {
|
||||
this.quietWindowMs = deps.quietWindowMs ?? 90_000;
|
||||
this.concurrency = Math.max(1, deps.concurrency ?? 2);
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.nowIso = deps.nowIso ?? (() => new Date().toISOString());
|
||||
}
|
||||
|
||||
enqueue(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
triggerReason: MemberWorkSyncTriggerReason;
|
||||
runAfterMs?: number;
|
||||
}): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamName = input.teamName.trim();
|
||||
const memberName = input.memberName.trim();
|
||||
if (!teamName || !memberName) {
|
||||
this.counters.dropped += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const key = keyOf(teamName, memberName);
|
||||
const runAt = this.now() + (input.runAfterMs ?? this.quietWindowMs);
|
||||
const running = this.running.get(key);
|
||||
if (running) {
|
||||
running.rerunRequested = true;
|
||||
running.triggerReasons.add(input.triggerReason);
|
||||
this.counters.coalesced += 1;
|
||||
this.appendAudit({
|
||||
teamName,
|
||||
memberName,
|
||||
event: 'queue_coalesced',
|
||||
source: 'event_queue',
|
||||
reason: input.triggerReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.items.get(key);
|
||||
if (existing) {
|
||||
existing.triggerReasons.add(input.triggerReason);
|
||||
existing.runAt = Math.max(existing.runAt, runAt);
|
||||
this.counters.coalesced += 1;
|
||||
this.appendAudit({
|
||||
teamName,
|
||||
memberName,
|
||||
event: 'queue_coalesced',
|
||||
source: 'event_queue',
|
||||
reason: input.triggerReason,
|
||||
});
|
||||
this.schedule();
|
||||
return;
|
||||
}
|
||||
|
||||
this.items.set(key, {
|
||||
teamName,
|
||||
memberName,
|
||||
runAt,
|
||||
triggerReasons: new Set([input.triggerReason]),
|
||||
});
|
||||
this.counters.enqueued += 1;
|
||||
this.appendAudit({
|
||||
teamName,
|
||||
memberName,
|
||||
event: 'queue_enqueued',
|
||||
source: 'event_queue',
|
||||
reason: input.triggerReason,
|
||||
});
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
dropTeam(teamName: string): void {
|
||||
for (const [key, item] of this.items) {
|
||||
if (item.teamName === teamName) {
|
||||
this.items.delete(key);
|
||||
this.counters.dropped += 1;
|
||||
}
|
||||
}
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
getDiagnostics(): MemberWorkSyncQueueDiagnostics {
|
||||
return {
|
||||
queued: this.items.size,
|
||||
running: this.running.size,
|
||||
...this.counters,
|
||||
};
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.items.clear();
|
||||
await Promise.allSettled([...this.inFlight]);
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
if (this.items.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.running.size >= this.concurrency) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRunAt = Math.min(...[...this.items.values()].map((item) => item.runAt));
|
||||
const delayMs = Math.max(0, nextRunAt - this.now());
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.pump();
|
||||
}, delayMs);
|
||||
unrefTimer(this.timer);
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const due = [...this.items.entries()]
|
||||
.filter(([, item]) => item.runAt <= this.now())
|
||||
.sort((left, right) => left[1].runAt - right[1].runAt);
|
||||
|
||||
for (const [key, item] of due) {
|
||||
if (this.running.size >= this.concurrency) {
|
||||
break;
|
||||
}
|
||||
this.items.delete(key);
|
||||
this.runItem(key, item);
|
||||
}
|
||||
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private runItem(key: string, item: QueueItem): void {
|
||||
const running: RunningItem = {
|
||||
rerunRequested: false,
|
||||
triggerReasons: new Set(item.triggerReasons),
|
||||
};
|
||||
this.running.set(key, running);
|
||||
|
||||
const promise = this.executeItem(key, item, running)
|
||||
.catch((error: unknown) => {
|
||||
this.counters.failed += 1;
|
||||
this.deps.logger?.warn('member work sync queue reconcile failed', {
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
error: String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.running.delete(key);
|
||||
this.inFlight.delete(promise);
|
||||
if (running.rerunRequested && !this.stopped) {
|
||||
for (const reason of running.triggerReasons) {
|
||||
this.enqueue({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
triggerReason: reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.pump();
|
||||
});
|
||||
|
||||
this.inFlight.add(promise);
|
||||
}
|
||||
|
||||
private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise<void> {
|
||||
if (!(await this.deps.isTeamActive(item.teamName))) {
|
||||
this.counters.dropped += 1;
|
||||
this.appendAudit({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
event: 'queue_dropped',
|
||||
source: 'event_queue',
|
||||
reason: 'team_inactive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deps.reconcile(
|
||||
{ teamName: item.teamName, memberName: item.memberName },
|
||||
{
|
||||
reconciledBy: 'queue',
|
||||
triggerReasons: [...running.triggerReasons].sort(),
|
||||
}
|
||||
);
|
||||
this.counters.reconciled += 1;
|
||||
this.appendAudit({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
event: 'queue_reconciled',
|
||||
source: 'event_queue',
|
||||
triggerReasons: [...running.triggerReasons].sort(),
|
||||
});
|
||||
}
|
||||
|
||||
private appendAudit(input: Omit<MemberWorkSyncAuditEvent, 'timestamp'>): void {
|
||||
if (!this.deps.auditJournal) {
|
||||
return;
|
||||
}
|
||||
void this.deps.auditJournal
|
||||
.append({
|
||||
...input,
|
||||
timestamp: this.nowIso(),
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.deps.logger?.warn('member work sync queue audit append failed', {
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
event: input.event,
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import type {
|
||||
MemberWorkSyncLoggerPort,
|
||||
MemberWorkSyncNudgeDispatchSummary,
|
||||
} from '../../core/application';
|
||||
|
||||
const DEFAULT_NUDGE_DISPATCH_INTERVAL_MS = 60_000;
|
||||
|
||||
function uniqueNonEmpty(values: string[]): string[] {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncNudgeDispatchSchedulerDeps {
|
||||
listLifecycleActiveTeamNames(): Promise<string[]>;
|
||||
dispatchDue(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
|
||||
intervalMs?: number;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
export class MemberWorkSyncNudgeDispatchScheduler {
|
||||
private readonly intervalMs: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private running: Promise<void> | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly deps: MemberWorkSyncNudgeDispatchSchedulerDeps) {
|
||||
this.intervalMs = Math.max(10_000, deps.intervalMs ?? DEFAULT_NUDGE_DISPATCH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.stopped || this.timer) {
|
||||
return;
|
||||
}
|
||||
this.schedule(this.intervalMs);
|
||||
}
|
||||
|
||||
async runOnce(): Promise<void> {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
if (this.running) {
|
||||
await this.running;
|
||||
return;
|
||||
}
|
||||
|
||||
const work = this.dispatchOnce();
|
||||
this.running = work;
|
||||
try {
|
||||
await work;
|
||||
} finally {
|
||||
if (this.running === work) {
|
||||
this.running = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
if (this.running) {
|
||||
await this.running.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.runOnce().finally(() => this.schedule(this.intervalMs));
|
||||
}, delayMs);
|
||||
unrefTimer(this.timer);
|
||||
}
|
||||
|
||||
private async dispatchOnce(): Promise<void> {
|
||||
try {
|
||||
const teamNames = uniqueNonEmpty(await this.deps.listLifecycleActiveTeamNames());
|
||||
if (teamNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
const summary = await this.deps.dispatchDue(teamNames);
|
||||
if (summary.claimed > 0 || summary.delivered > 0 || summary.retryable > 0) {
|
||||
this.deps.logger?.debug('member work sync scheduled nudge dispatch completed', {
|
||||
teamCount: teamNames.length,
|
||||
...summary,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logger?.warn('member work sync scheduled nudge dispatch failed', {
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { TeamMemberStoragePaths } from '@main/services/team/TeamMemberStoragePaths';
|
||||
import { join } from 'path';
|
||||
|
||||
export class MemberWorkSyncStorePaths {
|
||||
private readonly memberStorage: TeamMemberStoragePaths;
|
||||
|
||||
constructor(private readonly teamsBasePath: string) {
|
||||
this.memberStorage = new TeamMemberStoragePaths(teamsBasePath);
|
||||
}
|
||||
|
||||
getTeamRootDir(teamName: string): string {
|
||||
return join(this.teamsBasePath, teamName);
|
||||
}
|
||||
|
||||
getTeamDir(teamName: string): string {
|
||||
return join(this.teamsBasePath, teamName, '.member-work-sync');
|
||||
}
|
||||
|
||||
getStatusPath(teamName: string): string {
|
||||
return join(this.getTeamDir(teamName), 'status.json');
|
||||
}
|
||||
|
||||
getPendingReportsPath(teamName: string): string {
|
||||
return join(this.getTeamDir(teamName), 'pending-reports.json');
|
||||
}
|
||||
|
||||
getOutboxPath(teamName: string): string {
|
||||
return join(this.getTeamDir(teamName), 'outbox.json');
|
||||
}
|
||||
|
||||
getReportTokenSecretPath(teamName: string): string {
|
||||
return join(this.getTeamDir(teamName), 'report-token-secret.json');
|
||||
}
|
||||
|
||||
getIndexesDir(teamName: string): string {
|
||||
return join(this.getTeamDir(teamName), 'indexes');
|
||||
}
|
||||
|
||||
getMetricsIndexPath(teamName: string): string {
|
||||
return join(this.getIndexesDir(teamName), 'metrics.json');
|
||||
}
|
||||
|
||||
getOutboxIndexPath(teamName: string): string {
|
||||
return join(this.getIndexesDir(teamName), 'outbox-index.json');
|
||||
}
|
||||
|
||||
getPendingReportsIndexPath(teamName: string): string {
|
||||
return join(this.getIndexesDir(teamName), 'pending-reports-index.json');
|
||||
}
|
||||
|
||||
getLegacyStatusPath(teamName: string): string {
|
||||
return this.getStatusPath(teamName);
|
||||
}
|
||||
|
||||
getLegacyPendingReportsPath(teamName: string): string {
|
||||
return this.getPendingReportsPath(teamName);
|
||||
}
|
||||
|
||||
getLegacyOutboxPath(teamName: string): string {
|
||||
return this.getOutboxPath(teamName);
|
||||
}
|
||||
|
||||
getMemberKey(memberName: string): string {
|
||||
return this.memberStorage.getMemberKey(memberName);
|
||||
}
|
||||
|
||||
getMemberDir(teamName: string, memberName: string): string {
|
||||
return this.memberStorage.getMemberDir(teamName, memberName);
|
||||
}
|
||||
|
||||
getMemberWorkSyncDir(teamName: string, memberName: string): string {
|
||||
return this.memberStorage.getMemberFeatureDir(teamName, memberName, '.member-work-sync');
|
||||
}
|
||||
|
||||
getMemberStatusPath(teamName: string, memberName: string): string {
|
||||
return join(this.getMemberWorkSyncDir(teamName, memberName), 'status.json');
|
||||
}
|
||||
|
||||
getMemberReportsPath(teamName: string, memberName: string): string {
|
||||
return join(this.getMemberWorkSyncDir(teamName, memberName), 'reports.json');
|
||||
}
|
||||
|
||||
getMemberOutboxPath(teamName: string, memberName: string): string {
|
||||
return join(this.getMemberWorkSyncDir(teamName, memberName), 'outbox.json');
|
||||
}
|
||||
|
||||
getMemberJournalPath(teamName: string, memberName: string): string {
|
||||
return join(this.getMemberWorkSyncDir(teamName, memberName), 'journal.jsonl');
|
||||
}
|
||||
|
||||
async ensureMemberWorkSyncDir(teamName: string, memberName: string): Promise<void> {
|
||||
await this.memberStorage.ensureMemberMeta(teamName, memberName);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import type { MemberWorkSyncBusySignalPort } from '../../core/application';
|
||||
import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
|
||||
|
||||
const DEFAULT_TOOL_ACTIVITY_BUSY_GRACE_MS = 90_000;
|
||||
|
||||
interface MemberActivityState {
|
||||
activeToolIds: Set<string>;
|
||||
recentBusyUntilByToolId: Map<string, string>;
|
||||
}
|
||||
|
||||
function memberKey(teamName: string, memberName: string): string {
|
||||
return `${teamName}\0${memberName.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function parseToolActivity(detail: string | undefined): ToolActivityEventPayload | null {
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(detail) as ToolActivityEventPayload;
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseIsoMs(value: string | undefined, fallbackMs: number): number {
|
||||
const parsed = value ? Date.parse(value) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : fallbackMs;
|
||||
}
|
||||
|
||||
function addMsIso(baseIso: string, ms: number): string {
|
||||
return new Date(Date.parse(baseIso) + ms).toISOString();
|
||||
}
|
||||
|
||||
function maxIso(values: Iterable<string>): string | null {
|
||||
let max: string | null = null;
|
||||
for (const value of values) {
|
||||
if (!max || Date.parse(value) > Date.parse(max)) {
|
||||
max = value;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
export class MemberWorkSyncToolActivityBusySignal implements MemberWorkSyncBusySignalPort {
|
||||
private readonly activityByMember = new Map<string, MemberActivityState>();
|
||||
private readonly busyGraceMs: number;
|
||||
|
||||
constructor(options: { busyGraceMs?: number } = {}) {
|
||||
this.busyGraceMs = Math.max(0, options.busyGraceMs ?? DEFAULT_TOOL_ACTIVITY_BUSY_GRACE_MS);
|
||||
}
|
||||
|
||||
noteTeamChange(event: TeamChangeEvent): void {
|
||||
if (event.type === 'lead-activity' && event.detail === 'offline') {
|
||||
this.dropTeam(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type !== 'tool-activity') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseToolActivity(event.detail);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.action === 'start' && payload.activity) {
|
||||
this.noteStart(event.teamName, payload.activity.memberName, payload.activity.toolUseId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.action === 'finish' && payload.memberName && payload.toolUseId) {
|
||||
this.noteFinish(event.teamName, payload.memberName, payload.toolUseId, payload.finishedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.action === 'reset') {
|
||||
this.noteReset(event.teamName, payload.memberName, payload.toolUseIds);
|
||||
}
|
||||
}
|
||||
|
||||
async isBusy(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
nowIso: string;
|
||||
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }> {
|
||||
const key = memberKey(input.teamName, input.memberName);
|
||||
const state = this.activityByMember.get(key);
|
||||
if (!state) {
|
||||
return { busy: false };
|
||||
}
|
||||
|
||||
this.pruneState(key, state, input.nowIso);
|
||||
|
||||
if (state.activeToolIds.size > 0) {
|
||||
return {
|
||||
busy: true,
|
||||
reason: 'active_tool_activity',
|
||||
retryAfterIso: addMsIso(input.nowIso, this.busyGraceMs),
|
||||
};
|
||||
}
|
||||
|
||||
const retryAfterIso = maxIso(state.recentBusyUntilByToolId.values());
|
||||
if (retryAfterIso) {
|
||||
return {
|
||||
busy: true,
|
||||
reason: 'recent_tool_activity',
|
||||
retryAfterIso,
|
||||
};
|
||||
}
|
||||
|
||||
return { busy: false };
|
||||
}
|
||||
|
||||
private noteStart(teamName: string, memberName: string, toolUseId: string): void {
|
||||
const normalizedToolUseId = toolUseId.trim();
|
||||
if (!memberName.trim() || !normalizedToolUseId) {
|
||||
return;
|
||||
}
|
||||
const state = this.getOrCreateState(teamName, memberName);
|
||||
state.activeToolIds.add(normalizedToolUseId);
|
||||
state.recentBusyUntilByToolId.delete(normalizedToolUseId);
|
||||
}
|
||||
|
||||
private noteFinish(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
toolUseId: string,
|
||||
finishedAt: string | undefined
|
||||
): void {
|
||||
const normalizedToolUseId = toolUseId.trim();
|
||||
if (!memberName.trim() || !normalizedToolUseId) {
|
||||
return;
|
||||
}
|
||||
const finishedAtMs = parseIsoMs(finishedAt, Date.now());
|
||||
const busyUntilIso = new Date(finishedAtMs + this.busyGraceMs).toISOString();
|
||||
const state = this.getOrCreateState(teamName, memberName);
|
||||
state.activeToolIds.delete(normalizedToolUseId);
|
||||
state.recentBusyUntilByToolId.set(normalizedToolUseId, busyUntilIso);
|
||||
}
|
||||
|
||||
private noteReset(teamName: string, memberName?: string, toolUseIds?: string[]): void {
|
||||
const normalizedMemberName = memberName?.trim();
|
||||
if (!normalizedMemberName) {
|
||||
this.dropTeam(teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = memberKey(teamName, normalizedMemberName);
|
||||
const state = this.activityByMember.get(key);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedToolUseIds = new Set(
|
||||
(toolUseIds ?? []).map((toolUseId) => toolUseId.trim()).filter(Boolean)
|
||||
);
|
||||
if (normalizedToolUseIds.size === 0) {
|
||||
this.activityByMember.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const toolUseId of normalizedToolUseIds) {
|
||||
state.activeToolIds.delete(toolUseId);
|
||||
state.recentBusyUntilByToolId.delete(toolUseId);
|
||||
}
|
||||
if (state.activeToolIds.size === 0 && state.recentBusyUntilByToolId.size === 0) {
|
||||
this.activityByMember.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateState(teamName: string, memberName: string): MemberActivityState {
|
||||
const key = memberKey(teamName, memberName);
|
||||
const existing = this.activityByMember.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created: MemberActivityState = {
|
||||
activeToolIds: new Set(),
|
||||
recentBusyUntilByToolId: new Map(),
|
||||
};
|
||||
this.activityByMember.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private pruneState(key: string, state: MemberActivityState, nowIso: string): void {
|
||||
const nowMs = Date.parse(nowIso);
|
||||
for (const [toolUseId, busyUntilIso] of state.recentBusyUntilByToolId) {
|
||||
if (Date.parse(busyUntilIso) <= nowMs) {
|
||||
state.recentBusyUntilByToolId.delete(toolUseId);
|
||||
}
|
||||
}
|
||||
if (state.activeToolIds.size === 0 && state.recentBusyUntilByToolId.size === 0) {
|
||||
this.activityByMember.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private dropTeam(teamName: string): void {
|
||||
for (const key of this.activityByMember.keys()) {
|
||||
if (key.startsWith(`${teamName}\0`)) {
|
||||
this.activityByMember.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
import type { MemberWorkSyncHashPort } from '../../core/application';
|
||||
|
||||
export class NodeHashAdapter implements MemberWorkSyncHashPort {
|
||||
sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import {
|
||||
buildRuntimeTurnSettledSourceId,
|
||||
type RuntimeTurnSettledProvider,
|
||||
} from '../../core/domain';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncHashPort,
|
||||
RuntimeTurnSettledPayloadNormalization,
|
||||
RuntimeTurnSettledPayloadNormalizerPort,
|
||||
} from '../../core/application';
|
||||
|
||||
const SUPPORTED_SOURCE = 'agent-teams-orchestrator-opencode';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class OpenCodeTurnSettledPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort {
|
||||
constructor(private readonly hash: MemberWorkSyncHashPort) {}
|
||||
|
||||
normalize(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
raw: string;
|
||||
recordedAt: string;
|
||||
}): RuntimeTurnSettledPayloadNormalization {
|
||||
if (input.provider !== 'opencode') {
|
||||
return { ok: false, reason: 'unsupported_provider' };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input.raw);
|
||||
} catch {
|
||||
return { ok: false, reason: 'invalid_json' };
|
||||
}
|
||||
|
||||
const payload = asRecord(parsed);
|
||||
if (!payload) {
|
||||
return { ok: false, reason: 'payload_not_object' };
|
||||
}
|
||||
|
||||
const provider = getString(payload, 'provider');
|
||||
if (provider !== 'opencode') {
|
||||
return { ok: false, reason: 'provider_mismatch' };
|
||||
}
|
||||
|
||||
const source = getString(payload, 'source');
|
||||
if (source !== SUPPORTED_SOURCE) {
|
||||
return { ok: false, reason: 'source_mismatch' };
|
||||
}
|
||||
|
||||
const eventName = getString(payload, 'eventName', 'event_name');
|
||||
const hookEventName = getString(payload, 'hookEventName', 'hook_event_name');
|
||||
if (eventName !== 'runtime_turn_settled' && hookEventName !== 'Stop') {
|
||||
return { ok: false, reason: 'not_turn_settled_event' };
|
||||
}
|
||||
|
||||
const sessionId = getString(payload, 'sessionId', 'session_id', 'opencodeSessionId');
|
||||
const teamName = getString(payload, 'teamName', 'team_name', 'teamId', 'team_id');
|
||||
const memberName = getString(payload, 'memberName', 'member_name', 'agentName', 'agent_name');
|
||||
if (!sessionId) {
|
||||
return { ok: false, reason: 'missing_session_identity' };
|
||||
}
|
||||
if (!teamName || !memberName) {
|
||||
return { ok: false, reason: 'missing_team_member_identity' };
|
||||
}
|
||||
|
||||
const payloadHash = this.hash.sha256Hex(input.raw);
|
||||
const promptMessageId = getString(
|
||||
payload,
|
||||
'runtimePromptMessageId',
|
||||
'runtime_prompt_message_id',
|
||||
'promptMessageId',
|
||||
'prompt_message_id'
|
||||
);
|
||||
const turnId = getString(payload, 'turnId', 'turn_id') ?? promptMessageId;
|
||||
const cwd = getString(payload, 'cwd', 'projectPath', 'project_path');
|
||||
const agentId = getString(payload, 'agentId', 'agent_id', 'laneId', 'lane_id');
|
||||
const outcome = getString(payload, 'outcome');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
schemaVersion: 1,
|
||||
provider: 'opencode',
|
||||
hookEventName: 'Stop',
|
||||
payloadHash,
|
||||
recordedAt:
|
||||
getString(payload, 'recordedAt', 'recorded_at', 'observedAt', 'observed_at') ??
|
||||
input.recordedAt,
|
||||
sourceId: buildRuntimeTurnSettledSourceId({
|
||||
provider: 'opencode',
|
||||
sessionId,
|
||||
turnId,
|
||||
payloadHash,
|
||||
}),
|
||||
sessionId,
|
||||
...(turnId ? { turnId } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
teamName,
|
||||
memberName,
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(promptMessageId ? { threadId: promptMessageId } : {}),
|
||||
...(outcome ? { outcome } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type {
|
||||
MemberWorkSyncLoggerPort,
|
||||
RuntimeTurnSettledDrainSummary,
|
||||
} from '../../core/application';
|
||||
|
||||
export interface RuntimeTurnSettledDrainSchedulerDeps {
|
||||
drain(): Promise<RuntimeTurnSettledDrainSummary>;
|
||||
intervalMs?: number;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
export class RuntimeTurnSettledDrainScheduler {
|
||||
private readonly intervalMs: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private running = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly deps: RuntimeTurnSettledDrainSchedulerDeps) {
|
||||
this.intervalMs = Math.max(1_000, deps.intervalMs ?? 15_000);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.disposed || this.timer) {
|
||||
return;
|
||||
}
|
||||
this.schedule(100);
|
||||
}
|
||||
|
||||
async drainNow(): Promise<RuntimeTurnSettledDrainSummary | null> {
|
||||
if (this.running || this.disposed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
return await this.deps.drain();
|
||||
} catch (error) {
|
||||
this.deps.logger?.warn('runtime turn settled scheduled drain failed', {
|
||||
error: String(error),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(delayMs: number = this.intervalMs): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.drainNow().finally(() => this.schedule());
|
||||
}, delayMs);
|
||||
unrefTimer(this.timer);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment';
|
||||
import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings';
|
||||
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
||||
import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller';
|
||||
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
export class RuntimeTurnSettledSpoolInitializer {
|
||||
private readonly paths: RuntimeTurnSettledSpoolPaths;
|
||||
private readonly installer: ShellRuntimeTurnSettledHookScriptInstaller;
|
||||
|
||||
constructor(teamsBasePath: string) {
|
||||
this.paths = new RuntimeTurnSettledSpoolPaths(teamsBasePath);
|
||||
this.installer = new ShellRuntimeTurnSettledHookScriptInstaller(this.paths);
|
||||
}
|
||||
|
||||
getPaths(): RuntimeTurnSettledSpoolPaths {
|
||||
return this.paths;
|
||||
}
|
||||
|
||||
async buildHookSettings(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, unknown> | null> {
|
||||
if (input.provider !== 'claude') {
|
||||
return null;
|
||||
}
|
||||
const installed = await this.installer.install();
|
||||
return buildRuntimeTurnSettledHookSettings({
|
||||
scriptPath: installed.scriptPath,
|
||||
spoolRoot: installed.spoolRoot,
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
|
||||
async buildEnvironment(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
}): Promise<Record<string, string> | null> {
|
||||
if (input.provider !== 'codex' && input.provider !== 'opencode') {
|
||||
return null;
|
||||
}
|
||||
const installed = await this.installer.install();
|
||||
return buildRuntimeTurnSettledEnvironment({
|
||||
provider: input.provider,
|
||||
spoolRoot: installed.spoolRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import path from 'path';
|
||||
|
||||
export class RuntimeTurnSettledSpoolPaths {
|
||||
constructor(private readonly teamsBasePath: string) {}
|
||||
|
||||
getRootDir(): string {
|
||||
return path.join(this.teamsBasePath, '.member-work-sync', 'runtime-hooks');
|
||||
}
|
||||
|
||||
getBinDir(): string {
|
||||
return path.join(this.getRootDir(), 'bin');
|
||||
}
|
||||
|
||||
getHookScriptPath(): string {
|
||||
return path.join(this.getBinDir(), 'turn-settled-hook-v1.sh');
|
||||
}
|
||||
|
||||
getIncomingDir(): string {
|
||||
return path.join(this.getRootDir(), 'incoming');
|
||||
}
|
||||
|
||||
getProcessingDir(): string {
|
||||
return path.join(this.getRootDir(), 'processing');
|
||||
}
|
||||
|
||||
getProcessedDir(): string {
|
||||
return path.join(this.getRootDir(), 'processed');
|
||||
}
|
||||
|
||||
getInvalidDir(): string {
|
||||
return path.join(this.getRootDir(), 'invalid');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import fs from 'fs/promises';
|
||||
|
||||
import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
||||
|
||||
const HOOK_SCRIPT_CONTENT = `#!/bin/sh
|
||||
set +e
|
||||
|
||||
spool_root="$1"
|
||||
provider="$2"
|
||||
max_bytes="\${3:-262144}"
|
||||
|
||||
if [ -z "$spool_root" ] || [ -z "$provider" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$provider" in
|
||||
claude|codex) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
incoming="$spool_root/incoming"
|
||||
mkdir -p "$incoming" 2>/dev/null || exit 0
|
||||
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo unknown-time)"
|
||||
tmp="$(mktemp "$incoming/.turn-settled.XXXXXX" 2>/dev/null)" || exit 0
|
||||
suffix="$(basename "$tmp" | sed 's/^\\.turn-settled\\.//')"
|
||||
final="$incoming/$stamp-$$-$suffix.$provider.json"
|
||||
|
||||
dd bs="$max_bytes" count=1 of="$tmp" 2>/dev/null || {
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ ! -s "$tmp" ]; then
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mv "$tmp" "$final" 2>/dev/null || {
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
exit 0
|
||||
`;
|
||||
|
||||
export class ShellRuntimeTurnSettledHookScriptInstaller {
|
||||
constructor(private readonly paths: RuntimeTurnSettledSpoolPaths) {}
|
||||
|
||||
async install(): Promise<{ scriptPath: string; spoolRoot: string }> {
|
||||
await Promise.all([
|
||||
fs.mkdir(this.paths.getBinDir(), { recursive: true }),
|
||||
fs.mkdir(this.paths.getIncomingDir(), { recursive: true }),
|
||||
fs.mkdir(this.paths.getProcessingDir(), { recursive: true }),
|
||||
fs.mkdir(this.paths.getProcessedDir(), { recursive: true }),
|
||||
fs.mkdir(this.paths.getInvalidDir(), { recursive: true }),
|
||||
]);
|
||||
|
||||
const scriptPath = this.paths.getHookScriptPath();
|
||||
await fs.writeFile(scriptPath, HOOK_SCRIPT_CONTENT, 'utf8');
|
||||
// eslint-disable-next-line sonarjs/file-permissions -- the hook script must be executable on POSIX hosts.
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
|
||||
return {
|
||||
scriptPath,
|
||||
spoolRoot: this.paths.getRootDir(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { MemberWorkSyncClockPort } from '../../core/application';
|
||||
|
||||
export class SystemClockAdapter implements MemberWorkSyncClockPort {
|
||||
now(): Date {
|
||||
return new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT';
|
||||
|
||||
export function buildRuntimeTurnSettledEnvironment(input: {
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
spoolRoot: string;
|
||||
}): Record<string, string> | null {
|
||||
if (input.provider !== 'codex' && input.provider !== 'opencode') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
|
||||
export const MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER =
|
||||
'agent-teams:member-work-sync-turn-settled:v1';
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function buildRuntimeTurnSettledHookCommand(input: {
|
||||
scriptPath: string;
|
||||
spoolRoot: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
maxBytes?: number;
|
||||
}): string {
|
||||
return [
|
||||
'/bin/sh',
|
||||
shellQuote(input.scriptPath),
|
||||
shellQuote(input.spoolRoot),
|
||||
shellQuote(input.provider),
|
||||
shellQuote(String(input.maxBytes ?? 262_144)),
|
||||
'#',
|
||||
MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function buildRuntimeTurnSettledHookSettings(input: {
|
||||
scriptPath: string;
|
||||
spoolRoot: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
maxBytes?: number;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
matcher: '',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: buildRuntimeTurnSettledHookCommand(input),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
27
src/features/member-work-sync/preload/index.ts
Normal file
27
src/features/member-work-sync/preload/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import {
|
||||
MEMBER_WORK_SYNC_GET_METRICS,
|
||||
MEMBER_WORK_SYNC_GET_STATUS,
|
||||
MEMBER_WORK_SYNC_REPORT,
|
||||
type MemberWorkSyncMetricsRequest,
|
||||
type MemberWorkSyncReportRequest,
|
||||
type MemberWorkSyncReportResult,
|
||||
type MemberWorkSyncStatus,
|
||||
type MemberWorkSyncStatusRequest,
|
||||
type MemberWorkSyncTeamMetrics,
|
||||
} from '../contracts';
|
||||
|
||||
import type { IpcRenderer } from 'electron';
|
||||
|
||||
export interface MemberWorkSyncElectronApi {
|
||||
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
|
||||
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
|
||||
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
|
||||
}
|
||||
|
||||
export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi {
|
||||
return {
|
||||
getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request),
|
||||
getMetrics: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_METRICS, request),
|
||||
report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
|
||||
export type MemberWorkSyncViewTone = 'neutral' | 'success' | 'working' | 'attention' | 'blocked';
|
||||
|
||||
export interface MemberWorkSyncStatusViewModel {
|
||||
label: 'Synced' | 'Working' | 'Needs sync' | 'Blocked' | 'Unknown';
|
||||
tone: MemberWorkSyncViewTone;
|
||||
actionableCount: number;
|
||||
tooltip: string;
|
||||
fingerprint?: string;
|
||||
leaseExpiresAt?: string;
|
||||
reportState?: string;
|
||||
wouldNudge?: boolean;
|
||||
}
|
||||
|
||||
function describeAgenda(count: number): string {
|
||||
if (count === 0) {
|
||||
return 'No actionable work items.';
|
||||
}
|
||||
if (count === 1) {
|
||||
return '1 actionable work item.';
|
||||
}
|
||||
return `${count} actionable work items.`;
|
||||
}
|
||||
|
||||
export function toMemberWorkSyncStatusViewModel(
|
||||
status: MemberWorkSyncStatus | null | undefined
|
||||
): MemberWorkSyncStatusViewModel {
|
||||
if (!status) {
|
||||
return {
|
||||
label: 'Unknown',
|
||||
tone: 'neutral',
|
||||
actionableCount: 0,
|
||||
tooltip: 'Member work sync status has not been evaluated yet.',
|
||||
};
|
||||
}
|
||||
|
||||
const actionableCount = status.agenda.items.length;
|
||||
const base = {
|
||||
actionableCount,
|
||||
fingerprint: status.agenda.fingerprint,
|
||||
...(status.report?.expiresAt ? { leaseExpiresAt: status.report.expiresAt } : {}),
|
||||
...(status.report?.state ? { reportState: status.report.state } : {}),
|
||||
...(status.shadow ? { wouldNudge: status.shadow.wouldNudge } : {}),
|
||||
};
|
||||
|
||||
if (status.state === 'caught_up') {
|
||||
return {
|
||||
...base,
|
||||
label: 'Synced',
|
||||
tone: 'success',
|
||||
tooltip: `Synced with current work agenda. ${describeAgenda(actionableCount)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.state === 'still_working') {
|
||||
return {
|
||||
...base,
|
||||
label: 'Working',
|
||||
tone: 'working',
|
||||
tooltip: `Member reported still working on current agenda. ${describeAgenda(actionableCount)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.state === 'blocked') {
|
||||
return {
|
||||
...base,
|
||||
label: 'Blocked',
|
||||
tone: 'blocked',
|
||||
tooltip: `Member reported blocked on current agenda. ${describeAgenda(actionableCount)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.state === 'needs_sync') {
|
||||
return {
|
||||
...base,
|
||||
label: 'Needs sync',
|
||||
tone: 'attention',
|
||||
tooltip: `Shadow status only: current agenda has no valid member report. ${describeAgenda(
|
||||
actionableCount
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
label: 'Unknown',
|
||||
tone: 'neutral',
|
||||
tooltip: `Member work sync is not active for this member. ${describeAgenda(actionableCount)}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import {
|
||||
type MemberWorkSyncStatusViewModel,
|
||||
toMemberWorkSyncStatusViewModel,
|
||||
} from '../adapters/memberWorkSyncStatusViewModel';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
|
||||
export interface UseMemberWorkSyncStatusOptions {
|
||||
teamName?: string | null;
|
||||
memberName?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMemberWorkSyncStatusResult {
|
||||
status: MemberWorkSyncStatus | null;
|
||||
viewModel: MemberWorkSyncStatusViewModel;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Failed to load member work sync status.';
|
||||
}
|
||||
|
||||
export function useMemberWorkSyncStatus({
|
||||
teamName,
|
||||
memberName,
|
||||
enabled = true,
|
||||
}: UseMemberWorkSyncStatusOptions): UseMemberWorkSyncStatusResult {
|
||||
const [status, setStatus] = useState<MemberWorkSyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedTeamName = teamName?.trim();
|
||||
const normalizedMemberName = memberName?.trim();
|
||||
|
||||
if (!enabled || !normalizedTeamName || !normalizedMemberName) {
|
||||
setStatus(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatus((current) =>
|
||||
current?.teamName === normalizedTeamName && current.memberName === normalizedMemberName
|
||||
? current
|
||||
: null
|
||||
);
|
||||
|
||||
api.memberWorkSync
|
||||
.getStatus({ teamName: normalizedTeamName, memberName: normalizedMemberName })
|
||||
.then((nextStatus) => {
|
||||
if (!cancelled) {
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
})
|
||||
.catch((nextError: unknown) => {
|
||||
if (!cancelled) {
|
||||
setStatus(null);
|
||||
setError(getErrorMessage(nextError));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, memberName, refreshKey, teamName]);
|
||||
|
||||
return {
|
||||
status,
|
||||
viewModel: toMemberWorkSyncStatusViewModel(status),
|
||||
loading,
|
||||
error,
|
||||
refresh: () => setRefreshKey((current) => current + 1),
|
||||
};
|
||||
}
|
||||
5
src/features/member-work-sync/renderer/index.ts
Normal file
5
src/features/member-work-sync/renderer/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './adapters/memberWorkSyncStatusViewModel';
|
||||
export * from './hooks/useMemberWorkSyncStatus';
|
||||
export * from './ui/MemberWorkSyncBadge';
|
||||
export * from './ui/MemberWorkSyncDetails';
|
||||
export * from './ui/MemberWorkSyncStatusPanel';
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
import {
|
||||
type MemberWorkSyncStatusViewModel,
|
||||
toMemberWorkSyncStatusViewModel,
|
||||
} from '../adapters/memberWorkSyncStatusViewModel';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import type React from 'react';
|
||||
|
||||
type MemberWorkSyncBadgeProps = Readonly<{
|
||||
status?: MemberWorkSyncStatus | null;
|
||||
viewModel?: MemberWorkSyncStatusViewModel;
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
const toneClassName: Record<MemberWorkSyncStatusViewModel['tone'], string> = {
|
||||
neutral: 'border-[var(--color-border)] text-[var(--color-text-muted)]',
|
||||
success: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-300',
|
||||
working: 'border-sky-500/25 bg-sky-500/10 text-sky-300',
|
||||
attention: 'border-amber-500/25 bg-amber-500/10 text-amber-200',
|
||||
blocked: 'border-red-500/25 bg-red-500/10 text-red-300',
|
||||
};
|
||||
|
||||
export function MemberWorkSyncBadge({
|
||||
status,
|
||||
viewModel,
|
||||
className,
|
||||
}: MemberWorkSyncBadgeProps): React.ReactElement {
|
||||
const resolved = viewModel ?? toMemberWorkSyncStatusViewModel(status);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'cursor-default whitespace-nowrap font-medium',
|
||||
toneClassName[resolved.tone],
|
||||
className
|
||||
)}
|
||||
title={resolved.tooltip}
|
||||
>
|
||||
{resolved.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { toMemberWorkSyncStatusViewModel } from '../adapters/memberWorkSyncStatusViewModel';
|
||||
|
||||
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import type React from 'react';
|
||||
|
||||
type MemberWorkSyncDetailsProps = Readonly<{
|
||||
status: MemberWorkSyncStatus | null;
|
||||
showDiagnostics?: boolean;
|
||||
}>;
|
||||
|
||||
function shortFingerprint(fingerprint?: string): string {
|
||||
if (!fingerprint) {
|
||||
return 'unknown';
|
||||
}
|
||||
const suffix = fingerprint.split(':').at(-1) ?? fingerprint;
|
||||
return suffix.length > 12 ? `${suffix.slice(0, 12)}...` : suffix;
|
||||
}
|
||||
|
||||
export function MemberWorkSyncDetails({
|
||||
status,
|
||||
showDiagnostics = false,
|
||||
}: MemberWorkSyncDetailsProps): React.ReactElement {
|
||||
const viewModel = toMemberWorkSyncStatusViewModel(status);
|
||||
const agendaItems = status?.agenda.items ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">Member work sync</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{viewModel.tooltip}</p>
|
||||
</div>
|
||||
<MemberWorkSyncBadge viewModel={viewModel} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Actionable items</dt>
|
||||
<dd className="font-medium text-[var(--color-text)]">{viewModel.actionableCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Fingerprint</dt>
|
||||
<dd className="font-mono text-[var(--color-text)]">
|
||||
{shortFingerprint(viewModel.fingerprint)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Report</dt>
|
||||
<dd className="font-medium text-[var(--color-text)]">
|
||||
{viewModel.reportState ?? 'none'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Shadow would nudge</dt>
|
||||
<dd className="font-medium text-[var(--color-text)]">
|
||||
{viewModel.wouldNudge ? 'yes' : 'no'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{agendaItems.length > 0 ? (
|
||||
<ul className="mt-3 space-y-1 text-xs text-[var(--color-text-secondary)]">
|
||||
{agendaItems.slice(0, 3).map((item) => (
|
||||
<li key={`${item.kind}:${item.taskId}`} className="truncate">
|
||||
#{item.displayId ?? item.taskId.slice(0, 8)} - {item.kind} - {item.subject}
|
||||
</li>
|
||||
))}
|
||||
{agendaItems.length > 3 ? (
|
||||
<li className="text-[var(--color-text-muted)]">
|
||||
{agendaItems.length - 3} more actionable item(s)
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{showDiagnostics && status?.diagnostics.length ? (
|
||||
<p className="mt-3 text-xs text-[var(--color-text-muted)]">
|
||||
Diagnostics: {status.diagnostics.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus';
|
||||
|
||||
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
|
||||
import { MemberWorkSyncDetails } from './MemberWorkSyncDetails';
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
type MemberWorkSyncStatusPanelProps = Readonly<{
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
enabled?: boolean;
|
||||
showDiagnostics?: boolean;
|
||||
}>;
|
||||
|
||||
export function MemberWorkSyncStatusPanel({
|
||||
teamName,
|
||||
memberName,
|
||||
enabled = true,
|
||||
showDiagnostics = false,
|
||||
}: MemberWorkSyncStatusPanelProps): React.ReactElement | null {
|
||||
const { status, viewModel, loading, error } = useMemberWorkSyncStatus({
|
||||
teamName,
|
||||
memberName,
|
||||
enabled,
|
||||
});
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
return <MemberWorkSyncDetails status={status} showDiagnostics={showDiagnostics} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">Member work sync</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||
{loading
|
||||
? 'Loading member work sync diagnostics.'
|
||||
: error
|
||||
? 'Member work sync diagnostics are unavailable.'
|
||||
: viewModel.tooltip}
|
||||
</p>
|
||||
</div>
|
||||
<MemberWorkSyncBadge viewModel={viewModel} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import type {
|
|||
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
|
||||
import type { TeamDataService } from '../services/team/TeamDataService';
|
||||
import type { TeamProvisioningService } from '../services/team/TeamProvisioningService';
|
||||
import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:routes');
|
||||
|
|
@ -46,6 +47,7 @@ export interface HttpServices {
|
|||
chunkBuilder: ChunkBuilder;
|
||||
dataCache: DataCache;
|
||||
recentProjectsFeature?: RecentProjectsFeatureFacade;
|
||||
memberWorkSyncFeature?: MemberWorkSyncFeatureFacade;
|
||||
updaterService: UpdaterService;
|
||||
sshConnectionManager: SshConnectionManager;
|
||||
teamDataService?: TeamDataService;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { access } from 'fs/promises';
|
|||
import { isAbsolute, join } from 'path';
|
||||
|
||||
import type { HttpServices } from './index';
|
||||
import type { MemberWorkSyncReportState } from '@features/member-work-sync/contracts';
|
||||
import type {
|
||||
EffortLevel,
|
||||
TeamCreateConfigRequest,
|
||||
|
|
@ -31,6 +32,10 @@ type CreateTeamBody = TeamCreateConfigRequest;
|
|||
class HttpBadRequestError extends Error {}
|
||||
class HttpFeatureUnavailableError extends Error {}
|
||||
|
||||
function isMemberWorkSyncReportState(value: string): value is MemberWorkSyncReportState {
|
||||
return value === 'still_working' || value === 'blocked' || value === 'caught_up';
|
||||
}
|
||||
|
||||
function getTeamProvisioningService(
|
||||
services: HttpServices
|
||||
): NonNullable<HttpServices['teamProvisioningService']> {
|
||||
|
|
@ -466,6 +471,15 @@ function withRuntimeTeamName(teamName: string, body: unknown): Record<string, un
|
|||
return { ...payload, teamName };
|
||||
}
|
||||
|
||||
function getMemberWorkSyncFeature(
|
||||
services: HttpServices
|
||||
): NonNullable<HttpServices['memberWorkSyncFeature']> {
|
||||
if (!services.memberWorkSyncFeature) {
|
||||
throw new HttpBadRequestError('Member work sync feature is unavailable');
|
||||
}
|
||||
return services.memberWorkSyncFeature;
|
||||
}
|
||||
|
||||
export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
app.get('/api/teams', async (_request, reply) => {
|
||||
try {
|
||||
|
|
@ -736,4 +750,113 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { teamName: string } }>(
|
||||
'/api/teams/:teamName/member-work-sync/metrics',
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const validatedTeamName = validateTeamName(request.params.teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return reply.status(400).send({ error: validatedTeamName.error });
|
||||
}
|
||||
return reply.send(
|
||||
await getMemberWorkSyncFeature(services).getMetrics({
|
||||
teamName: validatedTeamName.value!,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error(
|
||||
`Error in GET /api/teams/${request.params.teamName}/member-work-sync/metrics:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { teamName: string; memberName: string } }>(
|
||||
'/api/teams/:teamName/member-work-sync/:memberName',
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const validatedTeamName = validateTeamName(request.params.teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return reply.status(400).send({ error: validatedTeamName.error });
|
||||
}
|
||||
const memberName = request.params.memberName?.trim();
|
||||
if (!memberName) {
|
||||
return reply.status(400).send({ error: 'memberName is required' });
|
||||
}
|
||||
return reply.send(
|
||||
await getMemberWorkSyncFeature(services).getStatus({
|
||||
teamName: validatedTeamName.value!,
|
||||
memberName,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error(
|
||||
`Error in GET /api/teams/${request.params.teamName}/member-work-sync/${request.params.memberName}:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
|
||||
'/api/teams/:teamName/member-work-sync/report',
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const validatedTeamName = validateTeamName(request.params.teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return reply.status(400).send({ error: validatedTeamName.error });
|
||||
}
|
||||
const payload = withRuntimeTeamName(validatedTeamName.value!, request.body);
|
||||
const memberName = typeof payload.memberName === 'string' ? payload.memberName.trim() : '';
|
||||
const state = typeof payload.state === 'string' ? payload.state.trim() : '';
|
||||
const agendaFingerprint =
|
||||
typeof payload.agendaFingerprint === 'string' ? payload.agendaFingerprint.trim() : '';
|
||||
if (!memberName || !state || !agendaFingerprint) {
|
||||
return reply.status(400).send({
|
||||
error: 'memberName, state, and agendaFingerprint are required',
|
||||
});
|
||||
}
|
||||
if (!isMemberWorkSyncReportState(state)) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: 'state must be still_working, blocked, or caught_up' });
|
||||
}
|
||||
const taskIds = Array.isArray(payload.taskIds)
|
||||
? payload.taskIds.filter((taskId): taskId is string => typeof taskId === 'string')
|
||||
: undefined;
|
||||
return reply.send(
|
||||
await getMemberWorkSyncFeature(services).report({
|
||||
teamName: validatedTeamName.value!,
|
||||
memberName,
|
||||
state,
|
||||
agendaFingerprint,
|
||||
...(typeof payload.reportToken === 'string'
|
||||
? { reportToken: payload.reportToken }
|
||||
: {}),
|
||||
...(taskIds ? { taskIds } : {}),
|
||||
...(typeof payload.note === 'string' ? { note: payload.note } : {}),
|
||||
...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}),
|
||||
...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}),
|
||||
source: 'mcp',
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error(
|
||||
`Error in POST /api/teams/${request.params.teamName}/member-work-sync/report:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ import {
|
|||
type CodexModelCatalogFeatureFacade,
|
||||
createCodexModelCatalogFeature,
|
||||
} from '@features/codex-model-catalog/main';
|
||||
import {
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
type MemberWorkSyncFeatureFacade,
|
||||
registerMemberWorkSyncIpc,
|
||||
removeMemberWorkSyncIpc,
|
||||
} from '@features/member-work-sync/main';
|
||||
import {
|
||||
createRecentProjectsFeature,
|
||||
type RecentProjectsFeatureFacade,
|
||||
|
|
@ -173,16 +180,20 @@ import {
|
|||
SshConnectionManager,
|
||||
TaskBoundaryParser,
|
||||
TeamDataService,
|
||||
TeamKanbanManager,
|
||||
TeamLogSourceTracker,
|
||||
TeammateToolTracker,
|
||||
TeamMemberLogsFinder,
|
||||
TeamMembersMetaStore,
|
||||
TeamProvisioningService,
|
||||
TeamRuntimeAdapterRegistry,
|
||||
TeamTaskReader,
|
||||
TeamTaskStallJournal,
|
||||
TeamTaskStallMonitor,
|
||||
TeamTaskStallNotifier,
|
||||
TeamTaskStallPolicy,
|
||||
TeamTaskStallSnapshotSource,
|
||||
TeamTranscriptSourceLocator,
|
||||
UpdaterService,
|
||||
} from './services';
|
||||
|
||||
|
|
@ -237,12 +248,28 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
|||
|
||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||
try {
|
||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
provider: 'opencode',
|
||||
});
|
||||
if (turnSettledEnv) {
|
||||
Object.assign(bridgeEnv, turnSettledEnv);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bridge turn-settled spool unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const mcpEntry = mcpLaunchSpec.args[0];
|
||||
if (mcpEntry) {
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -558,6 +585,7 @@ let codexAccountFeature: CodexAccountFeatureFacade | null = null;
|
|||
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
|
||||
let recentProjectsFeature: RecentProjectsFeatureFacade;
|
||||
let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
|
||||
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
|
||||
let teamDataService: TeamDataService;
|
||||
let teamProvisioningService: TeamProvisioningService;
|
||||
let cliInstallerService: CliInstallerService;
|
||||
|
|
@ -777,6 +805,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
||||
const teamName = row.teamName.trim();
|
||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
|
||||
|
||||
if (
|
||||
teamDataService &&
|
||||
|
|
@ -1024,7 +1053,14 @@ async function initializeServices(): Promise<void> {
|
|||
cliInstallerService = new CliInstallerService();
|
||||
ptyTerminalService = new PtyTerminalService();
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource();
|
||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator();
|
||||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||
teamTranscriptSourceLocator.invalidateTeam(teamName);
|
||||
});
|
||||
const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource(
|
||||
teamTranscriptSourceLocator
|
||||
);
|
||||
const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource);
|
||||
const boardTaskActivityDetailService = new BoardTaskActivityDetailService(
|
||||
boardTaskActivityRecordSource
|
||||
|
|
@ -1033,7 +1069,15 @@ async function initializeServices(): Promise<void> {
|
|||
const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService(
|
||||
boardTaskActivityRecordSource
|
||||
);
|
||||
const boardTaskLogStreamService = new BoardTaskLogStreamService(boardTaskActivityRecordSource);
|
||||
const boardTaskLogStreamService = new BoardTaskLogStreamService(
|
||||
boardTaskActivityRecordSource,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
teamTranscriptSourceLocator
|
||||
);
|
||||
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
|
||||
teamMemberLogsFinder
|
||||
);
|
||||
|
|
@ -1073,10 +1117,9 @@ async function initializeServices(): Promise<void> {
|
|||
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
|
||||
|
||||
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
|
||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||
teamTaskStallMonitor = new TeamTaskStallMonitor(
|
||||
new ActiveTeamRegistry(teamDataService, teamLogSourceTracker),
|
||||
new TeamTaskStallSnapshotSource(),
|
||||
new TeamTaskStallSnapshotSource(teamTranscriptSourceLocator),
|
||||
new TeamTaskStallPolicy(),
|
||||
new TeamTaskStallJournal(),
|
||||
new TeamTaskStallNotifier(teamDataService, teamProvisioningService)
|
||||
|
|
@ -1172,6 +1215,7 @@ async function initializeServices(): Promise<void> {
|
|||
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
||||
forwardTeamChange(event);
|
||||
teamTaskStallMonitor?.noteTeamChange(event);
|
||||
memberWorkSyncFeature?.noteTeamChange(event);
|
||||
if (event.type === 'lead-activity' && event.detail === 'offline') {
|
||||
teammateToolTracker?.handleTeamOffline(event.teamName);
|
||||
}
|
||||
|
|
@ -1215,6 +1259,48 @@ async function initializeServices(): Promise<void> {
|
|||
logger: createLogger('Feature:RecentProjects'),
|
||||
});
|
||||
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
|
||||
memberWorkSyncFeature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
configReader: new TeamConfigReader(),
|
||||
taskReader: new TeamTaskReader(),
|
||||
kanbanManager: new TeamKanbanManager(),
|
||||
membersMetaStore: new TeamMembersMetaStore(),
|
||||
isTeamActive: (teamName) =>
|
||||
teamProvisioningService.isTeamAlive(teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(teamName),
|
||||
listLifecycleActiveTeamNames: async () => {
|
||||
const teams = await teamDataService.listTeams();
|
||||
return teams
|
||||
.filter(
|
||||
(team) =>
|
||||
!team.deletedAt &&
|
||||
(teamProvisioningService.isTeamAlive(team.teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(team.teamName))
|
||||
)
|
||||
.map((team) => team.teamName);
|
||||
},
|
||||
logger: createLogger('Feature:MemberWorkSync'),
|
||||
});
|
||||
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
||||
memberWorkSyncFeature
|
||||
? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input)
|
||||
: Promise.resolve(null)
|
||||
);
|
||||
teamProvisioningService.setRuntimeTurnSettledEnvironmentProvider((input) =>
|
||||
memberWorkSyncFeature
|
||||
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
|
||||
: Promise.resolve(null)
|
||||
);
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
const activeTeamNames = teams.filter((team) => !team.deletedAt).map((team) => team.teamName);
|
||||
await memberWorkSyncFeature?.replayPendingReports(activeTeamNames);
|
||||
await memberWorkSyncFeature?.enqueueStartupScan(activeTeamNames);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`)
|
||||
);
|
||||
codexAccountFeature = createCodexAccountFeature({
|
||||
logger: createLogger('Feature:CodexAccount'),
|
||||
configManager,
|
||||
|
|
@ -1282,6 +1368,7 @@ async function initializeServices(): Promise<void> {
|
|||
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
|
||||
registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature);
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||
|
|
@ -1335,6 +1422,7 @@ async function startHttpServer(
|
|||
chunkBuilder: activeContext.chunkBuilder,
|
||||
dataCache: activeContext.dataCache,
|
||||
recentProjectsFeature,
|
||||
memberWorkSyncFeature: memberWorkSyncFeature ?? undefined,
|
||||
updaterService,
|
||||
sshConnectionManager,
|
||||
teamDataService,
|
||||
|
|
@ -1457,6 +1545,8 @@ async function shutdownServices(): Promise<void> {
|
|||
codexModelCatalogFeature = null;
|
||||
await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose());
|
||||
codexAccountFeature = null;
|
||||
await runShutdownStep('member work sync dispose', () => memberWorkSyncFeature?.dispose());
|
||||
memberWorkSyncFeature = null;
|
||||
|
||||
if (ptyTerminalService) {
|
||||
await runShutdownStep('PTY terminals kill', () => ptyTerminalService.killAll());
|
||||
|
|
@ -1467,6 +1557,7 @@ async function shutdownServices(): Promise<void> {
|
|||
removeCodexAccountIpc(ipcMain);
|
||||
removeRecentProjectsIpc(ipcMain);
|
||||
removeRuntimeProviderManagementIpc(ipcMain);
|
||||
removeMemberWorkSyncIpc(ipcMain);
|
||||
});
|
||||
|
||||
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());
|
||||
|
|
|
|||
|
|
@ -1893,13 +1893,15 @@ async function handleLaunchTeam(
|
|||
|
||||
const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId;
|
||||
const effortValidation = parseOptionalTeamEffort(
|
||||
payload.effort ?? savedRequest.effort,
|
||||
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort,
|
||||
resolvedProviderId
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode ?? savedRequest.fastMode);
|
||||
const fastModeValidation = parseOptionalTeamFastMode(
|
||||
Object.hasOwn(payload, 'fastMode') ? payload.fastMode : savedRequest.fastMode
|
||||
);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
|
|
@ -1963,20 +1965,16 @@ async function handleLaunchTeam(
|
|||
if (!launchProviderBackendValidation.valid) {
|
||||
return { success: false, error: launchProviderBackendValidation.error };
|
||||
}
|
||||
const rawLaunchEffort =
|
||||
payload.effort ??
|
||||
persistedMeta?.effort ??
|
||||
persistedMeta?.launchIdentity?.selectedEffort ??
|
||||
undefined;
|
||||
const rawLaunchEffort = Object.hasOwn(payload, 'effort')
|
||||
? payload.effort
|
||||
: (persistedMeta?.effort ?? persistedMeta?.launchIdentity?.selectedEffort ?? undefined);
|
||||
const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const rawLaunchFastMode =
|
||||
payload.fastMode ??
|
||||
persistedMeta?.fastMode ??
|
||||
persistedMeta?.launchIdentity?.selectedFastMode ??
|
||||
undefined;
|
||||
const rawLaunchFastMode = Object.hasOwn(payload, 'fastMode')
|
||||
? payload.fastMode
|
||||
: (persistedMeta?.fastMode ?? persistedMeta?.launchIdentity?.selectedFastMode ?? undefined);
|
||||
const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
|
|
|
|||
|
|
@ -4,14 +4,24 @@ import { createLogger } from '@shared/utils/logger';
|
|||
|
||||
const logger = createLogger('Perf:EventLoop');
|
||||
|
||||
const DEFAULT_MAX_STALL_THRESHOLD_MS = 750;
|
||||
const DEFAULT_REPORT_INTERVAL_MS = 30_000;
|
||||
|
||||
let started = false;
|
||||
let currentOp: string | null = null;
|
||||
let lastReportAt = 0;
|
||||
|
||||
function isEnabled(): boolean {
|
||||
const raw = process.env.CLAUDE_TEAM_EVENT_LOOP_LAG_MONITOR_ENABLED?.trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||
}
|
||||
|
||||
export function setCurrentMainOp(op: string | null): void {
|
||||
currentOp = op;
|
||||
}
|
||||
|
||||
export function startEventLoopLagMonitor(): void {
|
||||
if (!isEnabled()) return;
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
|
|
@ -24,14 +34,19 @@ export function startEventLoopLagMonitor(): void {
|
|||
// Reset first so next window is clean even if logging throws
|
||||
h.reset();
|
||||
|
||||
// Only report meaningful stalls
|
||||
if (maxMs < 250) return;
|
||||
// Only report severe stalls. Sub-second blips are common during expected
|
||||
// Electron/main-process IO and are too noisy for default development logs.
|
||||
if (maxMs < DEFAULT_MAX_STALL_THRESHOLD_MS) return;
|
||||
|
||||
// For known IPC/main-thread operations we already emit operation-specific
|
||||
// timing diagnostics. Suppress the generic event-loop warning to avoid
|
||||
// duplicate noisy logs that do not add new debugging value.
|
||||
if (currentOp) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastReportAt < DEFAULT_REPORT_INTERVAL_MS) return;
|
||||
lastReportAt = now;
|
||||
|
||||
logger.warn(
|
||||
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
|
||||
(currentOp ? ` op=${currentOp}` : '')
|
||||
|
|
|
|||
|
|
@ -423,6 +423,10 @@ export class ProviderConnectionService {
|
|||
connection: await this.getConnectionInfo(provider.providerId),
|
||||
};
|
||||
|
||||
if (provider.providerId === 'anthropic') {
|
||||
return this.enrichAnthropicProviderStatus(withConnection);
|
||||
}
|
||||
|
||||
if (provider.providerId !== 'codex') {
|
||||
return withConnection;
|
||||
}
|
||||
|
|
@ -480,6 +484,32 @@ export class ProviderConnectionService {
|
|||
}
|
||||
}
|
||||
|
||||
private enrichAnthropicProviderStatus(provider: CliProviderStatus): CliProviderStatus {
|
||||
const connection = provider.connection;
|
||||
if (connection?.configuredAuthMode !== 'api_key') {
|
||||
return provider;
|
||||
}
|
||||
|
||||
if (connection.apiKeyConfigured) {
|
||||
return {
|
||||
...provider,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState:
|
||||
provider.verificationState === 'error' ? provider.verificationState : 'verified',
|
||||
statusMessage: 'Connected via API key',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: provider.verificationState === 'error' ? 'error' : 'unknown',
|
||||
statusMessage: 'API key mode is selected, but no Anthropic API credential is available yet.',
|
||||
};
|
||||
}
|
||||
|
||||
async enrichProviderStatuses(providers: CliProviderStatus[]): Promise<CliProviderStatus[]> {
|
||||
return Promise.all(providers.map((provider) => this.enrichProviderStatus(provider)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type JsonArray = unknown[];
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -13,10 +15,80 @@ function parseJsonSettingsObject(raw: string): JsonObject | null {
|
|||
}
|
||||
}
|
||||
|
||||
function isHookEntry(value: unknown): value is JsonObject {
|
||||
return isJsonObject(value) && Array.isArray(value.hooks);
|
||||
}
|
||||
|
||||
function getHookEntryDedupeKey(value: unknown): string | null {
|
||||
if (!isHookEntry(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hooks = Array.isArray(value.hooks) ? value.hooks : [];
|
||||
const commands = hooks
|
||||
.map((hook: unknown) =>
|
||||
isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null
|
||||
)
|
||||
.filter((command): command is string => Boolean(command));
|
||||
if (commands.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
matcher: typeof value.matcher === 'string' ? value.matcher : '',
|
||||
commands,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeHookEntryArrays(target: JsonArray, source: JsonArray): JsonArray {
|
||||
const merged = [...target];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of merged) {
|
||||
const key = getHookEntryDedupeKey(entry);
|
||||
if (key) {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of source) {
|
||||
const key = getHookEntryDedupeKey(entry);
|
||||
if (key && seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
merged.push(entry);
|
||||
if (key) {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject {
|
||||
const merged: JsonObject = { ...target };
|
||||
for (const [hookName, sourceValue] of Object.entries(source)) {
|
||||
const currentValue = merged[hookName];
|
||||
if (Array.isArray(currentValue) && Array.isArray(sourceValue)) {
|
||||
merged[hookName] = mergeHookEntryArrays(currentValue, sourceValue);
|
||||
continue;
|
||||
}
|
||||
if (isJsonObject(currentValue) && isJsonObject(sourceValue)) {
|
||||
merged[hookName] = deepMergeJsonObjects(currentValue, sourceValue);
|
||||
continue;
|
||||
}
|
||||
merged[hookName] = sourceValue;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject {
|
||||
const merged: JsonObject = { ...target };
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const current = merged[key];
|
||||
if (key === 'hooks' && isJsonObject(current) && isJsonObject(value)) {
|
||||
merged[key] = mergeHooksObject(current, value);
|
||||
continue;
|
||||
}
|
||||
if (isJsonObject(current) && isJsonObject(value)) {
|
||||
merged[key] = deepMergeJsonObjects(current, value);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -1185,7 +1185,7 @@ function mapRejectedHunkIndicesByHashStrict(
|
|||
error: 'Ledger partial reject hunk context is ambiguous; please re-review.',
|
||||
};
|
||||
}
|
||||
out.add(candidates[0]!);
|
||||
out.add(candidates[0]);
|
||||
}
|
||||
return { ok: true, indices: [...out].sort((a, b) => a - b) };
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue