chore(team): checkpoint current frontend work
This commit is contained in:
parent
9d7542e9c4
commit
f6e95f5b2f
64 changed files with 4620 additions and 413 deletions
|
|
@ -66,6 +66,33 @@ function normalizeActorKey(value) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) {
|
||||||
|
const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : '';
|
||||||
|
if (!reviewerName) return false;
|
||||||
|
const reviewerKey = normalizeActorKey(reviewerName);
|
||||||
|
const intervals = Array.isArray(task.reviewIntervals) ? [...task.reviewIntervals] : [];
|
||||||
|
const hasOpenForReviewer = intervals.some(
|
||||||
|
(interval) => !interval.completedAt && normalizeActorKey(interval.reviewer) === reviewerKey
|
||||||
|
);
|
||||||
|
if (hasOpenForReviewer) {
|
||||||
|
task.reviewIntervals = intervals;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
task.reviewIntervals = [...intervals, { reviewer: reviewerName, startedAt: timestamp }];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReviewIntervals(task, timestamp = new Date().toISOString()) {
|
||||||
|
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||||
|
let changed = false;
|
||||||
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||||
|
if (interval.completedAt) return interval;
|
||||||
|
changed = true;
|
||||||
|
return { ...interval, completedAt: timestamp };
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveKnownActorName(context, value, label) {
|
function resolveKnownActorName(context, value, label) {
|
||||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||||
if (!actor) return null;
|
if (!actor) return null;
|
||||||
|
|
@ -130,7 +157,9 @@ function getReviewStartActor(context, task, flags) {
|
||||||
return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer');
|
return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`);
|
throw new Error(
|
||||||
|
`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestReviewStartedActor(task) {
|
function getLatestReviewStartedActor(task) {
|
||||||
|
|
@ -155,18 +184,25 @@ function getLatestReviewStartedActor(task) {
|
||||||
|
|
||||||
function getReviewDecisionActor(context, task, flags, actionName) {
|
function getReviewDecisionActor(context, task, flags, actionName) {
|
||||||
const explicit = resolveKnownActorName(context, flags.from, 'review actor');
|
const explicit = resolveKnownActorName(context, flags.from, 'review actor');
|
||||||
const startedActor = tryResolveKnownActorName(context, getLatestReviewStartedActor(task), 'review actor');
|
const startedActor = tryResolveKnownActorName(
|
||||||
const assignedReviewer = tryResolveKnownActorName(context, getLatestReviewRequestedReviewer(task), 'reviewer');
|
context,
|
||||||
|
getLatestReviewStartedActor(task),
|
||||||
|
'review actor'
|
||||||
|
);
|
||||||
|
const assignedReviewer = tryResolveKnownActorName(
|
||||||
|
context,
|
||||||
|
getLatestReviewRequestedReviewer(task),
|
||||||
|
'reviewer'
|
||||||
|
);
|
||||||
const inferredActor =
|
const inferredActor =
|
||||||
startedActor &&
|
startedActor &&
|
||||||
(!assignedReviewer ||
|
(!assignedReviewer ||
|
||||||
resolveActorIdentityKey(context, startedActor) === resolveActorIdentityKey(context, assignedReviewer))
|
resolveActorIdentityKey(context, startedActor) ===
|
||||||
|
resolveActorIdentityKey(context, assignedReviewer))
|
||||||
? startedActor
|
? startedActor
|
||||||
: assignedReviewer;
|
: assignedReviewer;
|
||||||
const actor =
|
const actor =
|
||||||
explicit ||
|
explicit || inferredActor || resolveKnownActorName(context, 'team-lead', 'review actor');
|
||||||
inferredActor ||
|
|
||||||
resolveKnownActorName(context, 'team-lead', 'review actor');
|
|
||||||
assertMatchesAssignedReviewer(context, task, actor, actionName);
|
assertMatchesAssignedReviewer(context, task, actor, actionName);
|
||||||
return actor;
|
return actor;
|
||||||
}
|
}
|
||||||
|
|
@ -176,12 +212,16 @@ function assertReviewTransitionAllowed(context, task, transitionName) {
|
||||||
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
||||||
}
|
}
|
||||||
if (task.status !== 'completed') {
|
if (task.status !== 'completed') {
|
||||||
throw new Error(`Task #${task.displayId || task.id} must be completed before ${transitionName}`);
|
throw new Error(
|
||||||
|
`Task #${task.displayId || task.id} must be completed before ${transitionName}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewState = getEffectiveReviewState(context, task);
|
const reviewState = getEffectiveReviewState(context, task);
|
||||||
if (reviewState !== 'review') {
|
if (reviewState !== 'review') {
|
||||||
throw new Error(`Task #${task.displayId || task.id} must be in review before ${transitionName}`);
|
throw new Error(
|
||||||
|
`Task #${task.displayId || task.id} must be in review before ${transitionName}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return reviewState;
|
return reviewState;
|
||||||
}
|
}
|
||||||
|
|
@ -223,9 +263,14 @@ function startReview(context, taskId, flags = {}) {
|
||||||
|
|
||||||
if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
|
if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
|
||||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||||
const existingActor = typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
|
const existingActor =
|
||||||
|
typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
|
||||||
const existingActorValid = existingActor
|
const existingActorValid = existingActor
|
||||||
? Boolean(runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { allowLeadAliases: true }))
|
? Boolean(
|
||||||
|
runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, {
|
||||||
|
allowLeadAliases: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
const assignedReviewer = tryResolveKnownActorName(
|
const assignedReviewer = tryResolveKnownActorName(
|
||||||
context,
|
context,
|
||||||
|
|
@ -235,7 +280,8 @@ function startReview(context, taskId, flags = {}) {
|
||||||
const existingMatchesAssigned =
|
const existingMatchesAssigned =
|
||||||
!assignedReviewer ||
|
!assignedReviewer ||
|
||||||
(existingActorValid &&
|
(existingActorValid &&
|
||||||
resolveActorIdentityKey(context, existingActor) === resolveActorIdentityKey(context, assignedReviewer));
|
resolveActorIdentityKey(context, existingActor) ===
|
||||||
|
resolveActorIdentityKey(context, assignedReviewer));
|
||||||
const requestedActor =
|
const requestedActor =
|
||||||
typeof flags.from === 'string' && flags.from.trim()
|
typeof flags.from === 'string' && flags.from.trim()
|
||||||
? getReviewStartActor(context, task, flags)
|
? getReviewStartActor(context, task, flags)
|
||||||
|
|
@ -244,38 +290,52 @@ function startReview(context, taskId, flags = {}) {
|
||||||
existingActorValid &&
|
existingActorValid &&
|
||||||
existingMatchesAssigned &&
|
existingMatchesAssigned &&
|
||||||
requestedActor &&
|
requestedActor &&
|
||||||
resolveActorIdentityKey(context, existingActor) !== resolveActorIdentityKey(context, requestedActor)
|
resolveActorIdentityKey(context, existingActor) !==
|
||||||
|
resolveActorIdentityKey(context, requestedActor)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Task #${task.displayId || task.id} review is already started by ${existingActor}`);
|
throw new Error(
|
||||||
|
`Task #${task.displayId || task.id} review is already started by ${existingActor}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||||
if (!existingActorValid || !existingMatchesAssigned) {
|
if (!existingActorValid || !existingMatchesAssigned) {
|
||||||
const repairedActor = requestedActor || getReviewStartActor(context, task, flags);
|
const repairedActor = requestedActor || getReviewStartActor(context, task, flags);
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
tasks.updateTask(context, task.id, (t) => {
|
tasks.updateTask(context, task.id, (t) => {
|
||||||
|
openReviewInterval(t, repairedActor, timestamp);
|
||||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||||
type: 'review_started',
|
type: 'review_started',
|
||||||
from: prevReviewState,
|
from: prevReviewState,
|
||||||
to: 'review',
|
to: 'review',
|
||||||
actor: repairedActor,
|
actor: repairedActor,
|
||||||
|
timestamp,
|
||||||
});
|
});
|
||||||
t.reviewState = 'review';
|
t.reviewState = 'review';
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
tasks.updateTask(context, task.id, (t) => {
|
||||||
|
openReviewInterval(t, existingActor);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||||
}
|
}
|
||||||
|
|
||||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||||
const from = getReviewStartActor(context, task, flags);
|
const from = getReviewStartActor(context, task, flags);
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||||
tasks.updateTask(context, task.id, (t) => {
|
tasks.updateTask(context, task.id, (t) => {
|
||||||
|
openReviewInterval(t, from, timestamp);
|
||||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||||
type: 'review_started',
|
type: 'review_started',
|
||||||
from: prevReviewState,
|
from: prevReviewState,
|
||||||
to: 'review',
|
to: 'review',
|
||||||
actor: from,
|
actor: from,
|
||||||
|
timestamp,
|
||||||
});
|
});
|
||||||
t.reviewState = 'review';
|
t.reviewState = 'review';
|
||||||
return t;
|
return t;
|
||||||
|
|
@ -285,7 +345,10 @@ function startReview(context, taskId, flags = {}) {
|
||||||
try {
|
try {
|
||||||
kanban.clearKanban(context, task.id, { transition: 'rollback' });
|
kanban.clearKanban(context, task.id, { transition: 'rollback' });
|
||||||
} catch (rollbackError) {
|
} catch (rollbackError) {
|
||||||
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError);
|
warnNonCritical(
|
||||||
|
`[review] rollback failed while starting review for ${task.id}`,
|
||||||
|
rollbackError
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -296,17 +359,23 @@ function requestReview(context, taskId, flags = {}) {
|
||||||
const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => {
|
const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => {
|
||||||
const currentTask = tasks.getTask(context, taskId);
|
const currentTask = tasks.getTask(context, taskId);
|
||||||
if (currentTask.status !== 'completed') {
|
if (currentTask.status !== 'completed') {
|
||||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`);
|
throw new Error(
|
||||||
|
`Task #${currentTask.displayId || currentTask.id} must be completed before review`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextFrom =
|
const nextFrom =
|
||||||
resolveKnownActorName(context, flags.from, 'review requester') ||
|
resolveKnownActorName(context, flags.from, 'review requester') ||
|
||||||
resolveKnownActorName(context, 'team-lead', 'review requester');
|
resolveKnownActorName(context, 'team-lead', 'review requester');
|
||||||
const rawReviewer = getReviewer(context, flags);
|
const rawReviewer = getReviewer(context, flags);
|
||||||
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
|
const nextReviewer = rawReviewer
|
||||||
|
? resolveKnownActorName(context, rawReviewer, 'reviewer')
|
||||||
|
: null;
|
||||||
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||||
if (prevReviewState === 'approved') {
|
if (prevReviewState === 'approved') {
|
||||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
throw new Error(
|
||||||
|
`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -326,7 +395,10 @@ function requestReview(context, taskId, flags = {}) {
|
||||||
try {
|
try {
|
||||||
kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
|
kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
|
||||||
} catch (rollbackError) {
|
} catch (rollbackError) {
|
||||||
warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError);
|
warnNonCritical(
|
||||||
|
`[review] rollback failed while requesting review for ${currentTask.id}`,
|
||||||
|
rollbackError
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -383,7 +455,9 @@ function approveReview(context, taskId, flags = {}) {
|
||||||
|
|
||||||
if (prevReviewState === 'approved') {
|
if (prevReviewState === 'approved') {
|
||||||
if (currentTask.status !== 'completed') {
|
if (currentTask.status !== 'completed') {
|
||||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before approval`);
|
throw new Error(
|
||||||
|
`Task #${currentTask.displayId || currentTask.id} must be completed before approval`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
||||||
return {
|
return {
|
||||||
|
|
@ -399,15 +473,18 @@ function approveReview(context, taskId, flags = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
assertReviewTransitionAllowed(context, currentTask, 'approval');
|
assertReviewTransitionAllowed(context, currentTask, 'approval');
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
||||||
tasks.updateTask(context, currentTask.id, (t) => {
|
tasks.updateTask(context, currentTask.id, (t) => {
|
||||||
|
closeReviewIntervals(t, timestamp);
|
||||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||||
type: 'review_approved',
|
type: 'review_approved',
|
||||||
from: prevReviewState,
|
from: prevReviewState,
|
||||||
to: 'approved',
|
to: 'approved',
|
||||||
...(nextNote ? { note: nextNote } : {}),
|
...(nextNote ? { note: nextNote } : {}),
|
||||||
actor: nextFrom,
|
actor: nextFrom,
|
||||||
|
timestamp,
|
||||||
});
|
});
|
||||||
t.reviewState = 'approved';
|
t.reviewState = 'approved';
|
||||||
return t;
|
return t;
|
||||||
|
|
@ -472,15 +549,22 @@ function requestChanges(context, taskId, flags = {}) {
|
||||||
typeof flags.comment === 'string' && flags.comment.trim()
|
typeof flags.comment === 'string' && flags.comment.trim()
|
||||||
? flags.comment.trim()
|
? flags.comment.trim()
|
||||||
: 'Reviewer requested changes.';
|
: 'Reviewer requested changes.';
|
||||||
const prevReviewState = assertReviewTransitionAllowed(context, currentTask, 'requesting changes');
|
const prevReviewState = assertReviewTransitionAllowed(
|
||||||
|
context,
|
||||||
|
currentTask,
|
||||||
|
'requesting changes'
|
||||||
|
);
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
tasks.updateTask(context, currentTask.id, (t) => {
|
tasks.updateTask(context, currentTask.id, (t) => {
|
||||||
|
closeReviewIntervals(t, timestamp);
|
||||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||||
type: 'review_changes_requested',
|
type: 'review_changes_requested',
|
||||||
from: prevReviewState,
|
from: prevReviewState,
|
||||||
to: 'needsFix',
|
to: 'needsFix',
|
||||||
...(nextComment ? { note: nextComment } : {}),
|
...(nextComment ? { note: nextComment } : {}),
|
||||||
actor: nextFrom,
|
actor: nextFrom,
|
||||||
|
timestamp,
|
||||||
});
|
});
|
||||||
t.reviewState = 'needsFix';
|
t.reviewState = 'needsFix';
|
||||||
return t;
|
return t;
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ function normalizeTask(rawTask, filePath) {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!TASK_STATUSES.has(String(task.status || '').trim())) {
|
if (!TASK_STATUSES.has(String(task.status || '').trim())) {
|
||||||
throw new Error(`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`);
|
throw new Error(
|
||||||
|
`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
task.status = String(task.status).trim();
|
task.status = String(task.status).trim();
|
||||||
|
|
||||||
|
|
@ -121,10 +123,14 @@ function listTaskRows(paths, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.sort((a, b) => {
|
tasks.sort((a, b) => {
|
||||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
const byDisplay = String(a.displayId || a.id).localeCompare(
|
||||||
numeric: true,
|
String(b.displayId || b.id),
|
||||||
sensitivity: 'base',
|
undefined,
|
||||||
});
|
{
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
}
|
||||||
|
);
|
||||||
if (byDisplay !== 0) return byDisplay;
|
if (byDisplay !== 0) return byDisplay;
|
||||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||||
numeric: true,
|
numeric: true,
|
||||||
|
|
@ -144,7 +150,9 @@ function listTasks(paths, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTaskRef(paths, taskRef, options = {}) {
|
function resolveTaskRef(paths, taskRef, options = {}) {
|
||||||
const normalizedRef = String(taskRef || '').trim().replace(/^#/, '');
|
const normalizedRef = String(taskRef || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^#/, '');
|
||||||
if (!normalizedRef) {
|
if (!normalizedRef) {
|
||||||
throw new Error('Missing taskId');
|
throw new Error('Missing taskId');
|
||||||
}
|
}
|
||||||
|
|
@ -168,9 +176,7 @@ function resolveTaskRef(paths, taskRef, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const byDisplay = tasks.find(
|
const byDisplay = tasks.find(
|
||||||
(task) =>
|
(task) => task.displayId === normalizedRef && (includeDeleted || task.status !== 'deleted')
|
||||||
task.displayId === normalizedRef &&
|
|
||||||
(includeDeleted || task.status !== 'deleted')
|
|
||||||
);
|
);
|
||||||
if (byDisplay) {
|
if (byDisplay) {
|
||||||
return byDisplay.id;
|
return byDisplay.id;
|
||||||
|
|
@ -195,6 +201,17 @@ function appendHistoryEvent(events, event) {
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeOpenReviewIntervals(task, timestamp) {
|
||||||
|
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||||
|
let changed = false;
|
||||||
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||||
|
if (interval.completedAt) return interval;
|
||||||
|
changed = true;
|
||||||
|
return { ...interval, completedAt: timestamp };
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStatus(status) {
|
function normalizeStatus(status) {
|
||||||
const normalized = String(status || '').trim();
|
const normalized = String(status || '').trim();
|
||||||
return TASK_STATUSES.has(normalized) ? normalized : null;
|
return TASK_STATUSES.has(normalized) ? normalized : null;
|
||||||
|
|
@ -204,7 +221,10 @@ function parseRelationshipList(paths, value) {
|
||||||
const rawValues = Array.isArray(value)
|
const rawValues = Array.isArray(value)
|
||||||
? value
|
? value
|
||||||
: typeof value === 'string'
|
: typeof value === 'string'
|
||||||
? value.split(',').map((entry) => entry.trim()).filter(Boolean)
|
? value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return rawValues.map((entry) => resolveTaskRef(paths, entry));
|
return rawValues.map((entry) => resolveTaskRef(paths, entry));
|
||||||
|
|
@ -248,7 +268,9 @@ function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) {
|
||||||
? explicitDisplayId.trim()
|
? explicitDisplayId.trim()
|
||||||
: deriveDisplayId(canonicalId);
|
: deriveDisplayId(canonicalId);
|
||||||
|
|
||||||
const existing = new Set(listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id)));
|
const existing = new Set(
|
||||||
|
listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id))
|
||||||
|
);
|
||||||
if (!existing.has(preferred)) {
|
if (!existing.has(preferred)) {
|
||||||
return preferred;
|
return preferred;
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +332,9 @@ function createTask(paths, input = {}) {
|
||||||
? input.createdBy.trim()
|
? input.createdBy.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const createdAt =
|
const createdAt =
|
||||||
typeof input.createdAt === 'string' && input.createdAt.trim() ? input.createdAt.trim() : nowIso();
|
typeof input.createdAt === 'string' && input.createdAt.trim()
|
||||||
|
? input.createdAt.trim()
|
||||||
|
: nowIso();
|
||||||
const status = computeInitialStatus(paths, input, owner, blockedByIds);
|
const status = computeInitialStatus(paths, input, owner, blockedByIds);
|
||||||
const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId);
|
const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId);
|
||||||
|
|
||||||
|
|
@ -429,7 +453,10 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
||||||
if (task.status === status) {
|
if (task.status === status) {
|
||||||
if (status === 'deleted' || status === 'in_progress') {
|
if (status === 'deleted' || status === 'in_progress') {
|
||||||
task.reviewState = 'none';
|
task.reviewState = 'none';
|
||||||
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') {
|
} else if (
|
||||||
|
status === 'pending' &&
|
||||||
|
normalizeTaskReviewState(task.reviewState) !== 'needsFix'
|
||||||
|
) {
|
||||||
task.reviewState = 'none';
|
task.reviewState = 'none';
|
||||||
}
|
}
|
||||||
return task;
|
return task;
|
||||||
|
|
@ -447,6 +474,9 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
||||||
lastInterval.completedAt = timestamp;
|
lastInterval.completedAt = timestamp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (status === 'pending' || status === 'in_progress' || status === 'deleted') {
|
||||||
|
closeOpenReviewIntervals(task, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
|
task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
|
||||||
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
||||||
|
|
@ -531,16 +561,16 @@ function addTaskComment(paths, taskRef, text, options = {}) {
|
||||||
const comment = {
|
const comment = {
|
||||||
id: options.id || crypto.randomUUID(),
|
id: options.id || crypto.randomUUID(),
|
||||||
author:
|
author:
|
||||||
typeof options.author === 'string' && options.author.trim()
|
typeof options.author === 'string' && options.author.trim() ? options.author.trim() : 'user',
|
||||||
? options.author.trim()
|
|
||||||
: 'user',
|
|
||||||
text,
|
text,
|
||||||
createdAt:
|
createdAt:
|
||||||
typeof options.createdAt === 'string' && options.createdAt.trim()
|
typeof options.createdAt === 'string' && options.createdAt.trim()
|
||||||
? options.createdAt.trim()
|
? options.createdAt.trim()
|
||||||
: nowIso(),
|
: nowIso(),
|
||||||
type: options.type || 'regular',
|
type: options.type || 'regular',
|
||||||
...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}),
|
...(normalizeTaskRefs(options.taskRefs)
|
||||||
|
? { taskRefs: normalizeTaskRefs(options.taskRefs) }
|
||||||
|
: {}),
|
||||||
...(Array.isArray(options.attachments) && options.attachments.length > 0
|
...(Array.isArray(options.attachments) && options.attachments.length > 0
|
||||||
? { attachments: options.attachments }
|
? { attachments: options.attachments }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
@ -711,10 +741,14 @@ function getTaskFreshness(task) {
|
||||||
function compareTasksByFreshness(a, b) {
|
function compareTasksByFreshness(a, b) {
|
||||||
const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a);
|
const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a);
|
||||||
if (freshnessDiff !== 0) return freshnessDiff;
|
if (freshnessDiff !== 0) return freshnessDiff;
|
||||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
const byDisplay = String(a.displayId || a.id).localeCompare(
|
||||||
numeric: true,
|
String(b.displayId || b.id),
|
||||||
sensitivity: 'base',
|
undefined,
|
||||||
});
|
{
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
}
|
||||||
|
);
|
||||||
if (byDisplay !== 0) return byDisplay;
|
if (byDisplay !== 0) return byDisplay;
|
||||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||||
numeric: true,
|
numeric: true,
|
||||||
|
|
@ -756,7 +790,9 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
||||||
in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
|
in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
|
||||||
needs_fix: activeTasks.filter((task) => {
|
needs_fix: activeTasks.filter((task) => {
|
||||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||||
return task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix';
|
return (
|
||||||
|
task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix'
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
pending: activeTasks.filter((task) => {
|
pending: activeTasks.filter((task) => {
|
||||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,10 @@ describe('agent-teams-controller API', () => {
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
return {
|
return {
|
||||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||||
close: async () => await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
|
close: async () =>
|
||||||
|
await new Promise((resolve, reject) =>
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()))
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,8 +149,12 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(briefing).toContain('Implement carefully');
|
expect(briefing).toContain('Implement carefully');
|
||||||
expect(briefing).toContain('Working directory: /tmp/project-x');
|
expect(briefing).toContain('Working directory: /tmp/project-x');
|
||||||
expect(briefing).toContain('Task briefing for bob:');
|
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(
|
||||||
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
|
'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('member_work_sync_status and member_work_sync_report');
|
||||||
expect(briefing).toContain(
|
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.'
|
'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.'
|
||||||
|
|
@ -175,9 +182,7 @@ describe('agent-teams-controller API', () => {
|
||||||
);
|
);
|
||||||
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
|
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
|
||||||
expect(briefing).toContain('OpenCode bootstrap silence rule');
|
expect(briefing).toContain('OpenCode bootstrap silence rule');
|
||||||
expect(briefing).toContain(
|
expect(briefing).toContain('If it shows no actionable tasks, stop and wait silently.');
|
||||||
'If it shows no actionable tasks, stop and wait silently.'
|
|
||||||
);
|
|
||||||
expect(briefing).toContain(
|
expect(briefing).toContain(
|
||||||
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
|
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
|
||||||
);
|
);
|
||||||
|
|
@ -478,7 +483,10 @@ describe('agent-teams-controller API', () => {
|
||||||
owner: 'bob',
|
owner: 'bob',
|
||||||
});
|
});
|
||||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||||
controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' });
|
controller.tasks.addTaskComment(activeTask.id, {
|
||||||
|
from: 'bob',
|
||||||
|
text: 'Resumed work with latest context.',
|
||||||
|
});
|
||||||
const needsFixTask = controller.tasks.createTask({
|
const needsFixTask = controller.tasks.createTask({
|
||||||
subject: 'Fix after review',
|
subject: 'Fix after review',
|
||||||
owner: 'bob',
|
owner: 'bob',
|
||||||
|
|
@ -517,7 +525,9 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(ownerInbox[0].text).toContain('task_get');
|
expect(ownerInbox[0].text).toContain('task_get');
|
||||||
expect(ownerInbox[0].text).toContain('task_start');
|
expect(ownerInbox[0].text).toContain('task_start');
|
||||||
expect(ownerInbox[0].text).toContain('task_add_comment');
|
expect(ownerInbox[0].text).toContain('task_add_comment');
|
||||||
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
|
expect(ownerInbox[0].text).toContain(
|
||||||
|
'If you are idle and this task is ready to start, start it now.'
|
||||||
|
);
|
||||||
expect(ownerInbox[0].text).toContain(
|
expect(ownerInbox[0].text).toContain(
|
||||||
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
||||||
);
|
);
|
||||||
|
|
@ -527,7 +537,9 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(ownerInbox[0].text).toContain('Check the migration plan first.');
|
expect(ownerInbox[0].text).toContain('Check the migration plan first.');
|
||||||
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
|
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
|
||||||
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
|
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
|
||||||
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
|
expect(ownerInbox[3].text).toContain(
|
||||||
|
'If you are idle and this task is ready to start, start it now.'
|
||||||
|
);
|
||||||
expect(ownerInbox[3].text).toContain('task_add_comment');
|
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||||
|
|
||||||
const briefing = await controller.tasks.taskBriefing('bob');
|
const briefing = await controller.tasks.taskBriefing('bob');
|
||||||
|
|
@ -549,9 +561,7 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(briefing).toContain(`#${reviewTask.displayId}`);
|
expect(briefing).toContain(`#${reviewTask.displayId}`);
|
||||||
expect(briefing).toContain('reason=review_reviewer_missing');
|
expect(briefing).toContain('reason=review_reviewer_missing');
|
||||||
expect(briefing).toContain(`#${completedTask.displayId}`);
|
expect(briefing).toContain(`#${completedTask.displayId}`);
|
||||||
expect(briefing).not.toContain(
|
expect(briefing).not.toContain('Completed task description should stay out of compact rows');
|
||||||
'Completed task description should stay out of compact rows'
|
|
||||||
);
|
|
||||||
expect(briefing).toContain(`#${approvedTask.displayId}`);
|
expect(briefing).toContain(`#${approvedTask.displayId}`);
|
||||||
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
||||||
});
|
});
|
||||||
|
|
@ -709,12 +719,7 @@ describe('agent-teams-controller API', () => {
|
||||||
const firstEvent = restored.historyEvents[0];
|
const firstEvent = restored.historyEvents[0];
|
||||||
expect(firstEvent.status).toBe('pending');
|
expect(firstEvent.status).toBe('pending');
|
||||||
const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
|
const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
|
||||||
expect(statusChanges).toEqual([
|
expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']);
|
||||||
'in_progress',
|
|
||||||
'completed',
|
|
||||||
'deleted',
|
|
||||||
'pending',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tracks owner assignment history without duplicate same-owner events', () => {
|
it('tracks owner assignment history without duplicate same-owner events', () => {
|
||||||
|
|
@ -804,6 +809,10 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(reviewEvent.from).toBe('review');
|
expect(reviewEvent.from).toBe('review');
|
||||||
expect(reviewEvent.to).toBe('review');
|
expect(reviewEvent.to).toBe('review');
|
||||||
expect(reviewEvent.actor).toBe('alice');
|
expect(reviewEvent.actor).toBe('alice');
|
||||||
|
expect(updatedTask.reviewIntervals).toHaveLength(1);
|
||||||
|
expect(updatedTask.reviewIntervals[0].reviewer).toBe('alice');
|
||||||
|
expect(updatedTask.reviewIntervals[0].startedAt).toBeTruthy();
|
||||||
|
expect(updatedTask.reviewIntervals[0].completedAt).toBeUndefined();
|
||||||
|
|
||||||
// Idempotent: calling again should also succeed without duplicate events
|
// Idempotent: calling again should also succeed without duplicate events
|
||||||
const again = controller.review.startReview(task.id, { from: 'alice' });
|
const again = controller.review.startReview(task.id, { from: 'alice' });
|
||||||
|
|
@ -811,6 +820,35 @@ describe('agent-teams-controller API', () => {
|
||||||
const reloaded = controller.tasks.getTask(task.id);
|
const reloaded = controller.tasks.getTask(task.id);
|
||||||
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
|
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
|
||||||
expect(startedEvents).toHaveLength(1);
|
expect(startedEvents).toHaveLength(1);
|
||||||
|
expect(reloaded.reviewIntervals).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes review intervals when review is approved or changes are requested', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const approvedTask = controller.tasks.createTask({ subject: 'Approve review', owner: 'bob' });
|
||||||
|
|
||||||
|
controller.tasks.completeTask(approvedTask.id, 'bob');
|
||||||
|
controller.review.requestReview(approvedTask.id, { from: 'team-lead', reviewer: 'alice' });
|
||||||
|
controller.review.startReview(approvedTask.id, { from: 'alice' });
|
||||||
|
const approved = controller.review.approveReview(approvedTask.id, { from: 'alice' });
|
||||||
|
|
||||||
|
expect(approved.reviewIntervals).toHaveLength(1);
|
||||||
|
expect(approved.reviewIntervals[0].reviewer).toBe('alice');
|
||||||
|
expect(approved.reviewIntervals[0].completedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const changesTask = controller.tasks.createTask({ subject: 'Request changes', owner: 'bob' });
|
||||||
|
controller.tasks.completeTask(changesTask.id, 'bob');
|
||||||
|
controller.review.requestReview(changesTask.id, { from: 'team-lead', reviewer: 'alice' });
|
||||||
|
controller.review.startReview(changesTask.id, { from: 'alice' });
|
||||||
|
const changed = controller.review.requestChanges(changesTask.id, {
|
||||||
|
from: 'alice',
|
||||||
|
comment: 'Needs a fix.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(changed.reviewIntervals).toHaveLength(1);
|
||||||
|
expect(changed.reviewIntervals[0].reviewer).toBe('alice');
|
||||||
|
expect(changed.reviewIntervals[0].completedAt).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
|
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
|
||||||
|
|
@ -841,7 +879,10 @@ describe('agent-teams-controller API', () => {
|
||||||
it('uses the assigned reviewer when review_start omits from', async () => {
|
it('uses the assigned reviewer when review_start omits from', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({ subject: 'Queued for implicit reviewer', owner: 'bob' });
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Queued for implicit reviewer',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
controller.tasks.completeTask(task.id, 'bob');
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||||
|
|
@ -866,15 +907,23 @@ describe('agent-teams-controller API', () => {
|
||||||
'must be completed before approval'
|
'must be completed before approval'
|
||||||
);
|
);
|
||||||
|
|
||||||
const completedTask = controller.tasks.createTask({ subject: 'Completed but not review', owner: 'bob' });
|
const completedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Completed but not review',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||||
expect(() =>
|
expect(() =>
|
||||||
controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' })
|
controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' })
|
||||||
).toThrow('must be in review before requesting changes');
|
).toThrow('must be in review before requesting changes');
|
||||||
|
|
||||||
const deletedTask = controller.tasks.createTask({ subject: 'Deleted review task', owner: 'bob' });
|
const deletedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Deleted review task',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
controller.tasks.softDeleteTask(deletedTask.id, 'bob');
|
controller.tasks.softDeleteTask(deletedTask.id, 'bob');
|
||||||
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow('is deleted');
|
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow(
|
||||||
|
'is deleted'
|
||||||
|
);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' })
|
controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' })
|
||||||
).toThrow('is deleted');
|
).toThrow('is deleted');
|
||||||
|
|
@ -885,13 +934,19 @@ describe('agent-teams-controller API', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
const pendingTask = controller.tasks.createTask({ subject: 'Pending implementation', owner: 'bob' });
|
const pendingTask = controller.tasks.createTask({
|
||||||
|
subject: 'Pending implementation',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow(
|
expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow(
|
||||||
'must be completed before starting review'
|
'must be completed before starting review'
|
||||||
);
|
);
|
||||||
expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none');
|
expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none');
|
||||||
|
|
||||||
const completedTask = controller.tasks.createTask({ subject: 'Completed without review request', owner: 'bob' });
|
const completedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Completed without review request',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||||
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
|
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
|
||||||
'must be in review before starting review'
|
'must be in review before starting review'
|
||||||
|
|
@ -907,12 +962,18 @@ describe('agent-teams-controller API', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
const pendingTask = controller.tasks.createTask({ subject: 'Kanban bypass pending', owner: 'bob' });
|
const pendingTask = controller.tasks.createTask({
|
||||||
|
subject: 'Kanban bypass pending',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow(
|
expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow(
|
||||||
'must be completed before moving to APPROVED column'
|
'must be completed before moving to APPROVED column'
|
||||||
);
|
);
|
||||||
|
|
||||||
const completedTask = controller.tasks.createTask({ subject: 'Kanban bypass completed', owner: 'bob' });
|
const completedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Kanban bypass completed',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||||
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
|
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
|
||||||
'must be in review before moving to REVIEW column'
|
'must be in review before moving to REVIEW column'
|
||||||
|
|
@ -938,9 +999,9 @@ describe('agent-teams-controller API', () => {
|
||||||
controller.review.startReview(task.id, { from: 'alice' });
|
controller.review.startReview(task.id, { from: 'alice' });
|
||||||
controller.review.approveReview(task.id, { from: 'alice' });
|
controller.review.approveReview(task.id, { from: 'alice' });
|
||||||
|
|
||||||
expect(() => controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })).toThrow(
|
expect(() =>
|
||||||
'is already approved'
|
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })
|
||||||
);
|
).toThrow('is already approved');
|
||||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||||
});
|
});
|
||||||
|
|
@ -963,7 +1024,9 @@ describe('agent-teams-controller API', () => {
|
||||||
controller.review.startReview(task.id, { from: 'alice' });
|
controller.review.startReview(task.id, { from: 'alice' });
|
||||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review');
|
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review');
|
||||||
expect(
|
expect(
|
||||||
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_started')
|
controller.tasks
|
||||||
|
.getTask(task.id)
|
||||||
|
.historyEvents.filter((event) => event.type === 'review_started')
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
|
|
||||||
controller.review.approveReview(task.id, { from: 'alice' });
|
controller.review.approveReview(task.id, { from: 'alice' });
|
||||||
|
|
@ -976,7 +1039,9 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(approvedAgain.alreadyApproved).toBe(true);
|
expect(approvedAgain.alreadyApproved).toBe(true);
|
||||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||||
expect(
|
expect(
|
||||||
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_approved')
|
controller.tasks
|
||||||
|
.getTask(task.id)
|
||||||
|
.historyEvents.filter((event) => event.type === 'review_approved')
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1189,7 +1254,11 @@ describe('agent-teams-controller API', () => {
|
||||||
it('wakes task owner on regular comment from another member', () => {
|
it('wakes task owner on regular comment from another member', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false });
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Investigate',
|
||||||
|
owner: 'bob',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
const commented = controller.tasks.addTaskComment(task.id, {
|
const commented = controller.tasks.addTaskComment(task.id, {
|
||||||
from: 'alice',
|
from: 'alice',
|
||||||
|
|
@ -1354,7 +1423,10 @@ describe('agent-teams-controller API', () => {
|
||||||
it('rejects task comments from unknown authors', () => {
|
it('rejects task comments from unknown authors', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({ subject: 'Reject unknown author', notifyOwner: false });
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Reject unknown author',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
controller.tasks.addTaskComment(task.id, {
|
controller.tasks.addTaskComment(task.id, {
|
||||||
|
|
@ -1374,7 +1446,10 @@ describe('agent-teams-controller API', () => {
|
||||||
claudeDir,
|
claudeDir,
|
||||||
allowUserMessageSender: false,
|
allowUserMessageSender: false,
|
||||||
});
|
});
|
||||||
const task = appController.tasks.createTask({ subject: 'Reserved comment authors', notifyOwner: false });
|
const task = appController.tasks.createTask({
|
||||||
|
subject: 'Reserved comment authors',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
const appComment = appController.tasks.addTaskComment(task.id, {
|
const appComment = appController.tasks.addTaskComment(task.id, {
|
||||||
from: 'user',
|
from: 'user',
|
||||||
|
|
@ -1803,11 +1878,19 @@ describe('agent-teams-controller API', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
|
const leadOwnedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Lead alias owner',
|
||||||
|
owner: 'lead',
|
||||||
|
});
|
||||||
expect(leadOwnedTask.owner).toBe('leadbot');
|
expect(leadOwnedTask.owner).toBe('leadbot');
|
||||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' });
|
const reassignedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Reassign alias owner',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
|
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
|
||||||
|
|
||||||
controller.kanban.addReviewer('lead');
|
controller.kanban.addReviewer('lead');
|
||||||
|
|
@ -1822,8 +1905,12 @@ describe('agent-teams-controller API', () => {
|
||||||
.historyEvents.filter((event) => event.type === 'review_requested')
|
.historyEvents.filter((event) => event.type === 'review_requested')
|
||||||
.at(-1);
|
.at(-1);
|
||||||
expect(requested.reviewer).toBe('leadbot');
|
expect(requested.reviewer).toBe('leadbot');
|
||||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(
|
||||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
true
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects task_briefing for unknown members', async () => {
|
it('rejects task_briefing for unknown members', async () => {
|
||||||
|
|
@ -1879,7 +1966,10 @@ describe('agent-teams-controller API', () => {
|
||||||
it('clears kanban tasks and column order when task_set_status deletes a review task', () => {
|
it('clears kanban tasks and column order when task_set_status deletes a review task', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({ subject: 'Generic status delete cleanup', owner: 'bob' });
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Generic status delete cleanup',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
controller.tasks.completeTask(task.id, 'bob');
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||||
|
|
@ -1956,7 +2046,10 @@ describe('agent-teams-controller API', () => {
|
||||||
it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => {
|
it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({ subject: 'Do not unapprove directly', owner: 'bob' });
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Do not unapprove directly',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
controller.tasks.completeTask(task.id, 'bob');
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||||
|
|
@ -1980,11 +2073,13 @@ describe('agent-teams-controller API', () => {
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' });
|
const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' });
|
||||||
|
|
||||||
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow('Unknown task owner: boob');
|
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow(
|
||||||
controller.tasks.completeTask(task.id, 'bob');
|
'Unknown task owner: boob'
|
||||||
expect(() => controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })).toThrow(
|
|
||||||
'Unknown reviewer: boob'
|
|
||||||
);
|
);
|
||||||
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
|
expect(() =>
|
||||||
|
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })
|
||||||
|
).toThrow('Unknown reviewer: boob');
|
||||||
|
|
||||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||||
|
|
@ -2006,8 +2101,12 @@ describe('agent-teams-controller API', () => {
|
||||||
|
|
||||||
controller.tasks.softDeleteTask(task.id, 'bob');
|
controller.tasks.softDeleteTask(task.id, 'bob');
|
||||||
|
|
||||||
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow('use task_restore before starting work');
|
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow(
|
||||||
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow('use task_restore before changing status');
|
'use task_restore before starting work'
|
||||||
|
);
|
||||||
|
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow(
|
||||||
|
'use task_restore before changing status'
|
||||||
|
);
|
||||||
expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
|
expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
|
||||||
'use task_restore before changing status'
|
'use task_restore before changing status'
|
||||||
);
|
);
|
||||||
|
|
@ -2020,7 +2119,10 @@ describe('agent-teams-controller API', () => {
|
||||||
it('rejects task_restore for non-deleted tasks', () => {
|
it('rejects task_restore for non-deleted tasks', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Approved task must stay approved',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
controller.tasks.completeTask(task.id, 'bob');
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||||
|
|
@ -2047,7 +2149,9 @@ describe('agent-teams-controller API', () => {
|
||||||
delete state.tasks[task.id];
|
delete state.tasks[task.id];
|
||||||
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
|
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
|
||||||
|
|
||||||
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)).toContain(task.id);
|
expect(
|
||||||
|
controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)
|
||||||
|
).toContain(task.id);
|
||||||
expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0);
|
expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2090,7 +2194,10 @@ describe('agent-teams-controller API', () => {
|
||||||
config.members.push({ name: 'carol', role: 'reviewer' });
|
config.members.push({ name: 'carol', role: 'reviewer' });
|
||||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({ subject: 'Repair mismatched reviewer actor', owner: 'bob' });
|
const task = controller.tasks.createTask({
|
||||||
|
subject: 'Repair mismatched reviewer actor',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
controller.tasks.completeTask(task.id, 'bob');
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||||
|
|
@ -2124,7 +2231,11 @@ describe('agent-teams-controller API', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const longSubject = `Long subject ${'x'.repeat(5000)}`;
|
const longSubject = `Long subject ${'x'.repeat(5000)}`;
|
||||||
const task = controller.tasks.createTask({ subject: longSubject, owner: 'bob', notifyOwner: false });
|
const task = controller.tasks.createTask({
|
||||||
|
subject: longSubject,
|
||||||
|
owner: 'bob',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
kanbanPath,
|
kanbanPath,
|
||||||
|
|
@ -2147,7 +2258,11 @@ describe('agent-teams-controller API', () => {
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
for (let index = 0; index < 30; index += 1) {
|
for (let index = 0; index < 30; index += 1) {
|
||||||
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`), '{ bad json', 'utf8');
|
fs.writeFileSync(
|
||||||
|
path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`),
|
||||||
|
'{ bad json',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const briefing = await controller.tasks.leadBriefing();
|
const briefing = await controller.tasks.leadBriefing();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="48%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#111827"/>
|
||||||
|
<stop offset="58%" stop-color="#08091a"/>
|
||||||
|
<stop offset="100%" stop-color="#050510"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<style>
|
||||||
|
.title{font:700 30px Inter,Arial,sans-serif;fill:#f8fafc;letter-spacing:0}
|
||||||
|
.sub{font:500 18px Inter,Arial,sans-serif;fill:#94a3b8;letter-spacing:0}
|
||||||
|
.edge{fill:none;stroke:#1d4ed8;stroke-width:2;opacity:.42}
|
||||||
|
.card{fill:#090b1d;stroke:#26314f;stroke-width:1.5}
|
||||||
|
.lead{fill:#142006;stroke:#9bef13;stroke-width:2;filter:url(#glow)}
|
||||||
|
.label{font:700 18px Inter,Arial,sans-serif;fill:#f8fafc;text-anchor:middle;letter-spacing:0}
|
||||||
|
.small{font:500 14px Inter,Arial,sans-serif;fill:#94a3b8;text-anchor:middle;letter-spacing:0}
|
||||||
|
.badge{font:700 12px Inter,Arial,sans-serif;fill:#020617;text-anchor:middle;letter-spacing:0}
|
||||||
|
</style>
|
||||||
|
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||||
|
<circle cx="0" cy="0" r="0.60" fill="#dbeafe" opacity="0.25"/>
|
||||||
|
<circle cx="97" cy="211" r="1.54" fill="#dbeafe" opacity="0.56"/>
|
||||||
|
<circle cx="194" cy="422" r="1.49" fill="#dbeafe" opacity="0.32"/>
|
||||||
|
<circle cx="291" cy="633" r="1.43" fill="#dbeafe" opacity="0.63"/>
|
||||||
|
<circle cx="388" cy="844" r="1.38" fill="#dbeafe" opacity="0.39"/>
|
||||||
|
<circle cx="485" cy="55" r="1.32" fill="#dbeafe" opacity="0.70"/>
|
||||||
|
<circle cx="582" cy="266" r="1.27" fill="#dbeafe" opacity="0.46"/>
|
||||||
|
<circle cx="679" cy="477" r="1.21" fill="#dbeafe" opacity="0.77"/>
|
||||||
|
<circle cx="776" cy="688" r="1.16" fill="#dbeafe" opacity="0.53"/>
|
||||||
|
<circle cx="873" cy="899" r="1.10" fill="#dbeafe" opacity="0.29"/>
|
||||||
|
<circle cx="970" cy="110" r="1.04" fill="#dbeafe" opacity="0.60"/>
|
||||||
|
<circle cx="1067" cy="321" r="0.99" fill="#dbeafe" opacity="0.36"/>
|
||||||
|
<circle cx="1164" cy="532" r="0.93" fill="#dbeafe" opacity="0.67"/>
|
||||||
|
<circle cx="1261" cy="743" r="0.88" fill="#dbeafe" opacity="0.43"/>
|
||||||
|
<circle cx="1358" cy="954" r="0.82" fill="#dbeafe" opacity="0.74"/>
|
||||||
|
<circle cx="55" cy="165" r="0.77" fill="#dbeafe" opacity="0.50"/>
|
||||||
|
<circle cx="152" cy="376" r="0.71" fill="#dbeafe" opacity="0.26"/>
|
||||||
|
<circle cx="249" cy="587" r="0.66" fill="#dbeafe" opacity="0.57"/>
|
||||||
|
<circle cx="346" cy="798" r="0.60" fill="#dbeafe" opacity="0.33"/>
|
||||||
|
<circle cx="443" cy="9" r="1.54" fill="#dbeafe" opacity="0.64"/>
|
||||||
|
<circle cx="540" cy="220" r="1.49" fill="#dbeafe" opacity="0.40"/>
|
||||||
|
<circle cx="637" cy="431" r="1.43" fill="#dbeafe" opacity="0.71"/>
|
||||||
|
<circle cx="734" cy="642" r="1.38" fill="#dbeafe" opacity="0.47"/>
|
||||||
|
<circle cx="831" cy="853" r="1.32" fill="#dbeafe" opacity="0.78"/>
|
||||||
|
<circle cx="928" cy="64" r="1.27" fill="#dbeafe" opacity="0.54"/>
|
||||||
|
<circle cx="1025" cy="275" r="1.21" fill="#dbeafe" opacity="0.30"/>
|
||||||
|
<circle cx="1122" cy="486" r="1.16" fill="#dbeafe" opacity="0.61"/>
|
||||||
|
<circle cx="1219" cy="697" r="1.10" fill="#dbeafe" opacity="0.37"/>
|
||||||
|
<circle cx="1316" cy="908" r="1.04" fill="#dbeafe" opacity="0.68"/>
|
||||||
|
<circle cx="13" cy="119" r="0.99" fill="#dbeafe" opacity="0.44"/>
|
||||||
|
<circle cx="110" cy="330" r="0.93" fill="#dbeafe" opacity="0.75"/>
|
||||||
|
<circle cx="207" cy="541" r="0.88" fill="#dbeafe" opacity="0.51"/>
|
||||||
|
<circle cx="304" cy="752" r="0.82" fill="#dbeafe" opacity="0.27"/>
|
||||||
|
<circle cx="401" cy="963" r="0.77" fill="#dbeafe" opacity="0.58"/>
|
||||||
|
<circle cx="498" cy="174" r="0.71" fill="#dbeafe" opacity="0.34"/>
|
||||||
|
<circle cx="595" cy="385" r="0.66" fill="#dbeafe" opacity="0.65"/>
|
||||||
|
<circle cx="692" cy="596" r="0.60" fill="#dbeafe" opacity="0.41"/>
|
||||||
|
<circle cx="789" cy="807" r="1.54" fill="#dbeafe" opacity="0.72"/>
|
||||||
|
<circle cx="886" cy="18" r="1.49" fill="#dbeafe" opacity="0.48"/>
|
||||||
|
<circle cx="983" cy="229" r="1.43" fill="#dbeafe" opacity="0.79"/>
|
||||||
|
<circle cx="1080" cy="440" r="1.38" fill="#dbeafe" opacity="0.55"/>
|
||||||
|
<circle cx="1177" cy="651" r="1.32" fill="#dbeafe" opacity="0.31"/>
|
||||||
|
<circle cx="1274" cy="862" r="1.27" fill="#dbeafe" opacity="0.62"/>
|
||||||
|
<circle cx="1371" cy="73" r="1.21" fill="#dbeafe" opacity="0.38"/>
|
||||||
|
<circle cx="68" cy="284" r="1.16" fill="#dbeafe" opacity="0.69"/>
|
||||||
|
<circle cx="165" cy="495" r="1.10" fill="#dbeafe" opacity="0.45"/>
|
||||||
|
<circle cx="262" cy="706" r="1.04" fill="#dbeafe" opacity="0.76"/>
|
||||||
|
<circle cx="359" cy="917" r="0.99" fill="#dbeafe" opacity="0.52"/>
|
||||||
|
<circle cx="456" cy="128" r="0.93" fill="#dbeafe" opacity="0.28"/>
|
||||||
|
<circle cx="553" cy="339" r="0.88" fill="#dbeafe" opacity="0.59"/>
|
||||||
|
<circle cx="650" cy="550" r="0.82" fill="#dbeafe" opacity="0.35"/>
|
||||||
|
<circle cx="747" cy="761" r="0.77" fill="#dbeafe" opacity="0.66"/>
|
||||||
|
<circle cx="844" cy="972" r="0.71" fill="#dbeafe" opacity="0.42"/>
|
||||||
|
<circle cx="941" cy="183" r="0.66" fill="#dbeafe" opacity="0.73"/>
|
||||||
|
<circle cx="1038" cy="394" r="0.60" fill="#dbeafe" opacity="0.49"/>
|
||||||
|
<circle cx="1135" cy="605" r="1.54" fill="#dbeafe" opacity="0.25"/>
|
||||||
|
<circle cx="1232" cy="816" r="1.49" fill="#dbeafe" opacity="0.56"/>
|
||||||
|
<circle cx="1329" cy="27" r="1.43" fill="#dbeafe" opacity="0.32"/>
|
||||||
|
<circle cx="26" cy="238" r="1.38" fill="#dbeafe" opacity="0.63"/>
|
||||||
|
<circle cx="123" cy="449" r="1.32" fill="#dbeafe" opacity="0.39"/>
|
||||||
|
<circle cx="220" cy="660" r="1.27" fill="#dbeafe" opacity="0.70"/>
|
||||||
|
<circle cx="317" cy="871" r="1.21" fill="#dbeafe" opacity="0.46"/>
|
||||||
|
<circle cx="414" cy="82" r="1.16" fill="#dbeafe" opacity="0.77"/>
|
||||||
|
<circle cx="511" cy="293" r="1.10" fill="#dbeafe" opacity="0.53"/>
|
||||||
|
<circle cx="608" cy="504" r="1.04" fill="#dbeafe" opacity="0.29"/>
|
||||||
|
<circle cx="705" cy="715" r="0.99" fill="#dbeafe" opacity="0.60"/>
|
||||||
|
<circle cx="802" cy="926" r="0.93" fill="#dbeafe" opacity="0.36"/>
|
||||||
|
<circle cx="899" cy="137" r="0.88" fill="#dbeafe" opacity="0.67"/>
|
||||||
|
<circle cx="996" cy="348" r="0.82" fill="#dbeafe" opacity="0.43"/>
|
||||||
|
<circle cx="1093" cy="559" r="0.77" fill="#dbeafe" opacity="0.74"/>
|
||||||
|
<circle cx="1190" cy="770" r="0.71" fill="#dbeafe" opacity="0.50"/>
|
||||||
|
<circle cx="1287" cy="981" r="0.66" fill="#dbeafe" opacity="0.26"/>
|
||||||
|
<circle cx="1384" cy="192" r="0.60" fill="#dbeafe" opacity="0.57"/>
|
||||||
|
<circle cx="81" cy="403" r="1.54" fill="#dbeafe" opacity="0.33"/>
|
||||||
|
<circle cx="178" cy="614" r="1.49" fill="#dbeafe" opacity="0.64"/>
|
||||||
|
<circle cx="275" cy="825" r="1.43" fill="#dbeafe" opacity="0.40"/>
|
||||||
|
<circle cx="372" cy="36" r="1.38" fill="#dbeafe" opacity="0.71"/>
|
||||||
|
<circle cx="469" cy="247" r="1.32" fill="#dbeafe" opacity="0.47"/>
|
||||||
|
<circle cx="566" cy="458" r="1.27" fill="#dbeafe" opacity="0.78"/>
|
||||||
|
<circle cx="663" cy="669" r="1.21" fill="#dbeafe" opacity="0.54"/>
|
||||||
|
<circle cx="760" cy="880" r="1.16" fill="#dbeafe" opacity="0.30"/>
|
||||||
|
<circle cx="857" cy="91" r="1.10" fill="#dbeafe" opacity="0.61"/>
|
||||||
|
<circle cx="954" cy="302" r="1.04" fill="#dbeafe" opacity="0.37"/>
|
||||||
|
<circle cx="1051" cy="513" r="0.99" fill="#dbeafe" opacity="0.68"/>
|
||||||
|
<circle cx="1148" cy="724" r="0.93" fill="#dbeafe" opacity="0.44"/>
|
||||||
|
<circle cx="1245" cy="935" r="0.88" fill="#dbeafe" opacity="0.75"/>
|
||||||
|
<circle cx="1342" cy="146" r="0.82" fill="#dbeafe" opacity="0.51"/>
|
||||||
|
<circle cx="39" cy="357" r="0.77" fill="#dbeafe" opacity="0.27"/>
|
||||||
|
<circle cx="136" cy="568" r="0.71" fill="#dbeafe" opacity="0.58"/>
|
||||||
|
<circle cx="233" cy="779" r="0.66" fill="#dbeafe" opacity="0.34"/>
|
||||||
|
<circle cx="330" cy="990" r="0.60" fill="#dbeafe" opacity="0.65"/>
|
||||||
|
<circle cx="427" cy="201" r="1.54" fill="#dbeafe" opacity="0.41"/>
|
||||||
|
<circle cx="524" cy="412" r="1.49" fill="#dbeafe" opacity="0.72"/>
|
||||||
|
<circle cx="621" cy="623" r="1.43" fill="#dbeafe" opacity="0.48"/>
|
||||||
|
<circle cx="718" cy="834" r="1.38" fill="#dbeafe" opacity="0.79"/>
|
||||||
|
<circle cx="815" cy="45" r="1.32" fill="#dbeafe" opacity="0.55"/>
|
||||||
|
<circle cx="912" cy="256" r="1.27" fill="#dbeafe" opacity="0.31"/>
|
||||||
|
<circle cx="1009" cy="467" r="1.21" fill="#dbeafe" opacity="0.62"/>
|
||||||
|
<circle cx="1106" cy="678" r="1.16" fill="#dbeafe" opacity="0.38"/>
|
||||||
|
<circle cx="1203" cy="889" r="1.10" fill="#dbeafe" opacity="0.69"/>
|
||||||
|
<circle cx="1300" cy="100" r="1.04" fill="#dbeafe" opacity="0.45"/>
|
||||||
|
<circle cx="1397" cy="311" r="0.99" fill="#dbeafe" opacity="0.76"/>
|
||||||
|
<circle cx="94" cy="522" r="0.93" fill="#dbeafe" opacity="0.52"/>
|
||||||
|
<circle cx="191" cy="733" r="0.88" fill="#dbeafe" opacity="0.28"/>
|
||||||
|
<circle cx="288" cy="944" r="0.82" fill="#dbeafe" opacity="0.59"/>
|
||||||
|
<circle cx="385" cy="155" r="0.77" fill="#dbeafe" opacity="0.35"/>
|
||||||
|
<circle cx="482" cy="366" r="0.71" fill="#dbeafe" opacity="0.66"/>
|
||||||
|
<circle cx="579" cy="577" r="0.66" fill="#dbeafe" opacity="0.42"/>
|
||||||
|
<circle cx="676" cy="788" r="0.60" fill="#dbeafe" opacity="0.73"/>
|
||||||
|
<circle cx="773" cy="999" r="1.54" fill="#dbeafe" opacity="0.49"/>
|
||||||
|
<circle cx="870" cy="210" r="1.49" fill="#dbeafe" opacity="0.25"/>
|
||||||
|
<circle cx="967" cy="421" r="1.43" fill="#dbeafe" opacity="0.56"/>
|
||||||
|
<circle cx="1064" cy="632" r="1.38" fill="#dbeafe" opacity="0.32"/>
|
||||||
|
<circle cx="1161" cy="843" r="1.32" fill="#dbeafe" opacity="0.63"/>
|
||||||
|
<circle cx="1258" cy="54" r="1.27" fill="#dbeafe" opacity="0.39"/>
|
||||||
|
<circle cx="1355" cy="265" r="1.21" fill="#dbeafe" opacity="0.70"/>
|
||||||
|
<circle cx="52" cy="476" r="1.16" fill="#dbeafe" opacity="0.46"/>
|
||||||
|
<circle cx="149" cy="687" r="1.10" fill="#dbeafe" opacity="0.77"/>
|
||||||
|
<circle cx="246" cy="898" r="1.04" fill="#dbeafe" opacity="0.53"/>
|
||||||
|
<circle cx="343" cy="109" r="0.99" fill="#dbeafe" opacity="0.29"/>
|
||||||
|
<text x="70" y="76" class="title">4 participants - current radial layout</text>
|
||||||
|
<text x="70" y="110" class="sub">Strict small-team preset: top / right / bottom / left around Lead</text>
|
||||||
|
<path d="M 700 500 C 700 500, 700 235, 700 235" class="edge"/>
|
||||||
|
<path d="M 700 500 C 895 500, 895 500, 1090 500" class="edge"/>
|
||||||
|
<path d="M 700 500 C 700 500, 700 765, 700 765" class="edge"/>
|
||||||
|
<path d="M 700 500 C 505 500, 505 500, 310 500" class="edge"/>
|
||||||
|
<rect x="615" y="457" width="170" height="86" rx="16" class="lead"/>
|
||||||
|
<text x="700" y="496" class="label">Lead</text>
|
||||||
|
<text x="700" y="524" class="small">center reserved zone</text>
|
||||||
|
|
||||||
|
<rect x="570" y="160" width="260" height="150" rx="10" class="card"/>
|
||||||
|
<circle cx="700" cy="187" r="20" fill="#38bdf8" opacity=".18" filter="url(#glow)"/>
|
||||||
|
<circle cx="700" cy="187" r="14" fill="#38bdf8"/>
|
||||||
|
<text x="700" y="227" class="label">Participant 1</text>
|
||||||
|
<text x="700" y="255" class="small">top side</text>
|
||||||
|
<rect x="658" y="278" width="84" height="24" rx="6" fill="#38bdf8"/>
|
||||||
|
<text x="700" y="295" class="badge">slot 1</text>
|
||||||
|
|
||||||
|
|
||||||
|
<rect x="960" y="425" width="260" height="150" rx="10" class="card"/>
|
||||||
|
<circle cx="1090" cy="452" r="20" fill="#facc15" opacity=".18" filter="url(#glow)"/>
|
||||||
|
<circle cx="1090" cy="452" r="14" fill="#facc15"/>
|
||||||
|
<text x="1090" y="492" class="label">Participant 2</text>
|
||||||
|
<text x="1090" y="520" class="small">right side</text>
|
||||||
|
<rect x="1048" y="543" width="84" height="24" rx="6" fill="#facc15"/>
|
||||||
|
<text x="1090" y="560" class="badge">slot 2</text>
|
||||||
|
|
||||||
|
|
||||||
|
<rect x="570" y="690" width="260" height="150" rx="10" class="card"/>
|
||||||
|
<circle cx="700" cy="717" r="20" fill="#ef4444" opacity=".18" filter="url(#glow)"/>
|
||||||
|
<circle cx="700" cy="717" r="14" fill="#ef4444"/>
|
||||||
|
<text x="700" y="757" class="label">Participant 3</text>
|
||||||
|
<text x="700" y="785" class="small">bottom side</text>
|
||||||
|
<rect x="658" y="808" width="84" height="24" rx="6" fill="#ef4444"/>
|
||||||
|
<text x="700" y="825" class="badge">slot 3</text>
|
||||||
|
|
||||||
|
|
||||||
|
<rect x="180" y="425" width="260" height="150" rx="10" class="card"/>
|
||||||
|
<circle cx="310" cy="452" r="20" fill="#a78bfa" opacity=".18" filter="url(#glow)"/>
|
||||||
|
<circle cx="310" cy="452" r="14" fill="#a78bfa"/>
|
||||||
|
<text x="310" y="492" class="label">Participant 4</text>
|
||||||
|
<text x="310" y="520" class="small">left side</text>
|
||||||
|
<rect x="268" y="543" width="84" height="24" rx="6" fill="#a78bfa"/>
|
||||||
|
<text x="310" y="560" class="badge">slot 4</text>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
125
docs/screenshots/agent-graph-row-orbit-layout-preview.svg
Normal file
125
docs/screenshots/agent-graph-row-orbit-layout-preview.svg
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<svg width="1800" height="1050" viewBox="0 0 1800 1050" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
|
||||||
|
<stop offset="0%" stop-color="#111733"/>
|
||||||
|
<stop offset="55%" stop-color="#090d20"/>
|
||||||
|
<stop offset="100%" stop-color="#050717"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||||
|
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.25 0 0 0 0 0.75 0 0 0 0 1 0 0 0 0.65 0"/>
|
||||||
|
<feBlend in="SourceGraphic"/>
|
||||||
|
</filter>
|
||||||
|
<style>
|
||||||
|
.panel-title { font: 700 28px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #e6f0ff; letter-spacing: 0; }
|
||||||
|
.panel-subtitle { font: 500 15px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
|
||||||
|
.label { font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #eff6ff; letter-spacing: 0; }
|
||||||
|
.role { font: 500 10px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8ea0bd; letter-spacing: 0; }
|
||||||
|
.hint { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
|
||||||
|
.card { fill: rgba(11, 16, 36, 0.84); stroke: rgba(148, 163, 184, 0.22); stroke-width: 1; }
|
||||||
|
.slot { fill: rgba(59, 130, 246, 0.035); stroke: rgba(125, 211, 252, 0.16); stroke-width: 1; stroke-dasharray: 5 7; }
|
||||||
|
.edge { stroke: rgba(96, 165, 250, 0.18); stroke-width: 2; }
|
||||||
|
.row-guide { stroke: rgba(148, 163, 184, 0.14); stroke-width: 1; stroke-dasharray: 6 10; }
|
||||||
|
.divider { stroke: rgba(148, 163, 184, 0.16); stroke-width: 1; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1800" height="1050" fill="url(#bg)"/>
|
||||||
|
<g opacity="0.85">
|
||||||
|
<circle cx="108" cy="102" r="1.5" fill="#94a3b8"/>
|
||||||
|
<circle cx="238" cy="860" r="1.2" fill="#64748b"/>
|
||||||
|
<circle cx="385" cy="145" r="1.3" fill="#94a3b8"/>
|
||||||
|
<circle cx="520" cy="950" r="1.4" fill="#cbd5e1"/>
|
||||||
|
<circle cx="690" cy="372" r="1.1" fill="#94a3b8"/>
|
||||||
|
<circle cx="823" cy="787" r="1.2" fill="#64748b"/>
|
||||||
|
<circle cx="982" cy="210" r="1.1" fill="#94a3b8"/>
|
||||||
|
<circle cx="1112" cy="902" r="1.5" fill="#cbd5e1"/>
|
||||||
|
<circle cx="1286" cy="118" r="1.2" fill="#94a3b8"/>
|
||||||
|
<circle cx="1458" cy="730" r="1.3" fill="#64748b"/>
|
||||||
|
<circle cx="1632" cy="340" r="1.5" fill="#cbd5e1"/>
|
||||||
|
<circle cx="1748" cy="934" r="1.1" fill="#94a3b8"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<line x1="900" y1="70" x2="900" y2="980" class="divider"/>
|
||||||
|
|
||||||
|
<text x="70" y="72" class="panel-title">8 participants</text>
|
||||||
|
<text x="70" y="101" class="panel-subtitle">3 top / 2 at lead level / 3 bottom</text>
|
||||||
|
<text x="970" y="72" class="panel-title">12 participants</text>
|
||||||
|
<text x="970" y="101" class="panel-subtitle">4 top / 2 + lead + 2 middle / 4 bottom</text>
|
||||||
|
|
||||||
|
<g id="eight-layout">
|
||||||
|
<line x1="110" y1="245" x2="790" y2="245" class="row-guide"/>
|
||||||
|
<line x1="110" y1="525" x2="790" y2="525" class="row-guide"/>
|
||||||
|
<line x1="110" y1="805" x2="790" y2="805" class="row-guide"/>
|
||||||
|
<text x="118" y="232" class="hint">top row</text>
|
||||||
|
<text x="118" y="512" class="hint">lead row</text>
|
||||||
|
<text x="118" y="792" class="hint">bottom row</text>
|
||||||
|
|
||||||
|
<path d="M450 525 C370 430 305 330 245 245" class="edge"/>
|
||||||
|
<path d="M450 525 C450 425 450 335 450 245" class="edge"/>
|
||||||
|
<path d="M450 525 C530 430 595 330 655 245" class="edge"/>
|
||||||
|
<path d="M450 525 C360 515 285 515 200 525" class="edge"/>
|
||||||
|
<path d="M450 525 C540 515 615 515 700 525" class="edge"/>
|
||||||
|
<path d="M450 525 C370 620 305 720 245 805" class="edge"/>
|
||||||
|
<path d="M450 525 C450 625 450 715 450 805" class="edge"/>
|
||||||
|
<path d="M450 525 C530 620 595 720 655 805" class="edge"/>
|
||||||
|
|
||||||
|
<g transform="translate(450 525)">
|
||||||
|
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
|
||||||
|
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
|
||||||
|
<circle r="17" fill="#84cc16"/>
|
||||||
|
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(245 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text><text y="34" text-anchor="middle" class="role">reviewer</text></g>
|
||||||
|
<g transform="translate(450 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||||
|
<g transform="translate(655 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||||
|
<g transform="translate(200 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||||
|
<g transform="translate(700 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text><text y="34" text-anchor="middle" class="role">assistant</text></g>
|
||||||
|
<g transform="translate(245 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||||
|
<g transform="translate(450 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text><text y="34" text-anchor="middle" class="role">qa</text></g>
|
||||||
|
<g transform="translate(655 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text><text y="34" text-anchor="middle" class="role">ops</text></g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="twelve-layout">
|
||||||
|
<line x1="970" y1="245" x2="1730" y2="245" class="row-guide"/>
|
||||||
|
<line x1="970" y1="525" x2="1730" y2="525" class="row-guide"/>
|
||||||
|
<line x1="970" y1="805" x2="1730" y2="805" class="row-guide"/>
|
||||||
|
<text x="978" y="232" class="hint">top row</text>
|
||||||
|
<text x="978" y="512" class="hint">lead row</text>
|
||||||
|
<text x="978" y="792" class="hint">bottom row</text>
|
||||||
|
|
||||||
|
<path d="M1350 525 C1245 425 1135 330 1030 245" class="edge"/>
|
||||||
|
<path d="M1350 525 C1295 420 1270 335 1243 245" class="edge"/>
|
||||||
|
<path d="M1350 525 C1405 420 1430 335 1457 245" class="edge"/>
|
||||||
|
<path d="M1350 525 C1455 425 1565 330 1670 245" class="edge"/>
|
||||||
|
<path d="M1350 525 C1235 515 1135 515 1030 525" class="edge"/>
|
||||||
|
<path d="M1350 525 C1270 520 1235 520 1210 525" class="edge"/>
|
||||||
|
<path d="M1350 525 C1430 520 1465 520 1490 525" class="edge"/>
|
||||||
|
<path d="M1350 525 C1465 515 1565 515 1670 525" class="edge"/>
|
||||||
|
<path d="M1350 525 C1245 625 1135 720 1030 805" class="edge"/>
|
||||||
|
<path d="M1350 525 C1295 630 1270 715 1243 805" class="edge"/>
|
||||||
|
<path d="M1350 525 C1405 630 1430 715 1457 805" class="edge"/>
|
||||||
|
<path d="M1350 525 C1455 625 1565 720 1670 805" class="edge"/>
|
||||||
|
|
||||||
|
<g transform="translate(1350 525)">
|
||||||
|
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
|
||||||
|
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
|
||||||
|
<circle r="17" fill="#84cc16"/>
|
||||||
|
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(1030 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text></g>
|
||||||
|
<g transform="translate(1243 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text></g>
|
||||||
|
<g transform="translate(1457 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text></g>
|
||||||
|
<g transform="translate(1670 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text></g>
|
||||||
|
<g transform="translate(1030 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text></g>
|
||||||
|
<g transform="translate(1210 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text></g>
|
||||||
|
<g transform="translate(1490 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text></g>
|
||||||
|
<g transform="translate(1670 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text></g>
|
||||||
|
<g transform="translate(1030 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#a855f7"/><text y="17" text-anchor="middle" class="label">ivy</text></g>
|
||||||
|
<g transform="translate(1243 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f43f5e"/><text y="17" text-anchor="middle" class="label">rex</text></g>
|
||||||
|
<g transform="translate(1457 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#38bdf8"/><text y="17" text-anchor="middle" class="label">zoe</text></g>
|
||||||
|
<g transform="translate(1670 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#84cc16"/><text y="17" text-anchor="middle" class="label">sam</text></g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -132,6 +132,7 @@ export default defineConfig({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
|
cacheDir: resolve(__dirname, 'node_modules/.vite/electron-renderer'),
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['@codemirror/language-data'],
|
include: ['@codemirror/language-data'],
|
||||||
exclude: ['@claude-teams/agent-graph']
|
exclude: ['@claude-teams/agent-graph']
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ export interface SlotFrame {
|
||||||
taskColumnCount: number;
|
taskColumnCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OwnerSlotLayoutKind = 'radial-sector' | 'row-orbit' | 'grid-under-lead';
|
||||||
|
|
||||||
export interface StableSlotLayoutSnapshot {
|
export interface StableSlotLayoutSnapshot {
|
||||||
version: GraphLayoutPort['version'];
|
version: GraphLayoutPort['version'];
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -61,6 +63,7 @@ export interface StableSlotLayoutSnapshot {
|
||||||
launchAnchor: { x: number; y: number } | null;
|
launchAnchor: { x: number; y: number } | null;
|
||||||
leadCentralReservedBlock: StableRect;
|
leadCentralReservedBlock: StableRect;
|
||||||
runtimeCentralExclusion: StableRect;
|
runtimeCentralExclusion: StableRect;
|
||||||
|
ownerSlotLayoutKind: OwnerSlotLayoutKind;
|
||||||
centralCollisionRects: StableRect[];
|
centralCollisionRects: StableRect[];
|
||||||
memberSlotFrames: SlotFrame[];
|
memberSlotFrames: SlotFrame[];
|
||||||
memberSlotFrameByOwnerId: Map<string, SlotFrame>;
|
memberSlotFrameByOwnerId: Map<string, SlotFrame>;
|
||||||
|
|
@ -104,6 +107,20 @@ interface RingLayoutState {
|
||||||
|
|
||||||
type RingLayoutStateMap = ReadonlyMap<string, RingLayoutState>;
|
type RingLayoutStateMap = ReadonlyMap<string, RingLayoutState>;
|
||||||
|
|
||||||
|
interface PlannedMemberSlotLayout {
|
||||||
|
frames: SlotFrame[];
|
||||||
|
kind: OwnerSlotLayoutKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowOrbitSlotConfig {
|
||||||
|
footprint: OwnerFootprint;
|
||||||
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
rowIndex: number;
|
||||||
|
columnIndex: number;
|
||||||
|
columnCount: number;
|
||||||
|
band: 'top' | 'middle' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
const SLOT_GEOMETRY = {
|
const SLOT_GEOMETRY = {
|
||||||
...STABLE_SLOT_GEOMETRY,
|
...STABLE_SLOT_GEOMETRY,
|
||||||
activityColumnHeight:
|
activityColumnHeight:
|
||||||
|
|
@ -129,11 +146,19 @@ const PROCESS_RAIL_NODE_GAP = 42;
|
||||||
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
|
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
|
||||||
const GEOMETRY_EPSILON = 0.001;
|
const GEOMETRY_EPSILON = 0.001;
|
||||||
const FEED_HEADER_BOTTOM_GAP = 4;
|
const FEED_HEADER_BOTTOM_GAP = 4;
|
||||||
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
|
const STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS = 96;
|
||||||
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
|
const STRICT_SMALL_TEAM_RADIUS_EPSILON = 0.5;
|
||||||
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
|
const STRICT_SMALL_TEAM_RADIUS_STEP = 24;
|
||||||
|
const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2;
|
||||||
|
const GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT = 3;
|
||||||
|
const GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT = 6;
|
||||||
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
|
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
|
||||||
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
|
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
|
||||||
|
const ROW_ORBIT_MIN_OWNER_COUNT = 6;
|
||||||
|
const ROW_ORBIT_MAX_OWNER_COUNT = 12;
|
||||||
|
const ROW_ORBIT_HORIZONTAL_GAP = Math.max(112, STABLE_SLOT_GEOMETRY.slotHorizontalGap);
|
||||||
|
const ROW_ORBIT_VERTICAL_GAP = Math.max(144, GRID_UNDER_LEAD_ROW_GAP);
|
||||||
|
const ROW_ORBIT_CENTRAL_GAP = 160;
|
||||||
|
|
||||||
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
|
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
|
||||||
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
|
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
|
||||||
|
|
@ -159,14 +184,48 @@ const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
|
||||||
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } },
|
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } },
|
||||||
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } },
|
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } },
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: SECTOR_VECTORS[3] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] },
|
||||||
|
{ assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] },
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> =
|
const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> =
|
||||||
SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment));
|
SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment));
|
||||||
const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map(
|
|
||||||
SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) =>
|
const ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT: Readonly<Record<number, readonly number[]>> = {
|
||||||
layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
|
6: [3, 2, 3],
|
||||||
)
|
7: [3, 2, 2],
|
||||||
|
8: [3, 2, 3],
|
||||||
|
9: [3, 2, 2, 2],
|
||||||
|
10: [3, 2, 2, 3],
|
||||||
|
11: [3, 3, 2, 3],
|
||||||
|
12: [3, 3, 3, 3],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT: Readonly<
|
||||||
|
Record<number, readonly GraphOwnerSlotAssignment[]>
|
||||||
|
> = Object.fromEntries(
|
||||||
|
Object.entries(ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT).map(([ownerCount, rowCounts]) => [
|
||||||
|
Number(ownerCount),
|
||||||
|
rowCounts.flatMap((columnCount, rowIndex) =>
|
||||||
|
Array.from({ length: columnCount }, (_, columnIndex) => ({
|
||||||
|
ringIndex: rowIndex,
|
||||||
|
sectorIndex: columnIndex,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
export function buildStableSlotLayoutSnapshot({
|
export function buildStableSlotLayoutSnapshot({
|
||||||
|
|
@ -201,10 +260,14 @@ export function buildStableSlotLayoutSnapshot({
|
||||||
SLOT_GEOMETRY.centralPadding
|
SLOT_GEOMETRY.centralPadding
|
||||||
);
|
);
|
||||||
|
|
||||||
const memberSlotFrames =
|
const memberSlotLayout =
|
||||||
(layout?.mode ?? 'radial') === 'grid-under-lead'
|
(layout?.mode ?? 'radial') === 'grid-under-lead'
|
||||||
? planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects)
|
? {
|
||||||
|
frames: planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects),
|
||||||
|
kind: 'grid-under-lead' as const,
|
||||||
|
}
|
||||||
: planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout);
|
: planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout);
|
||||||
|
const memberSlotFrames = memberSlotLayout.frames;
|
||||||
const memberSlotFrameByOwnerId = new Map(
|
const memberSlotFrameByOwnerId = new Map(
|
||||||
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
|
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
|
||||||
);
|
);
|
||||||
|
|
@ -223,6 +286,7 @@ export function buildStableSlotLayoutSnapshot({
|
||||||
launchAnchor: null,
|
launchAnchor: null,
|
||||||
leadCentralReservedBlock,
|
leadCentralReservedBlock,
|
||||||
runtimeCentralExclusion,
|
runtimeCentralExclusion,
|
||||||
|
ownerSlotLayoutKind: memberSlotLayout.kind,
|
||||||
centralCollisionRects,
|
centralCollisionRects,
|
||||||
memberSlotFrames,
|
memberSlotFrames,
|
||||||
memberSlotFrameByOwnerId,
|
memberSlotFrameByOwnerId,
|
||||||
|
|
@ -457,6 +521,21 @@ export function resolveNearestSlotAssignment(args: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.snapshot.ownerSlotLayoutKind === 'row-orbit') {
|
||||||
|
const rowOrbitCandidate = resolveNearestRowOrbitSlotAssignment({
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
ownerX: args.ownerX,
|
||||||
|
ownerY: args.ownerY,
|
||||||
|
currentFrame,
|
||||||
|
ownerFootprints: allFootprints,
|
||||||
|
snapshot: args.snapshot,
|
||||||
|
layout: args.layout,
|
||||||
|
});
|
||||||
|
if (rowOrbitCandidate) {
|
||||||
|
return rowOrbitCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({
|
const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({
|
||||||
ownerId: args.ownerId,
|
ownerId: args.ownerId,
|
||||||
ownerX: args.ownerX,
|
ownerX: args.ownerX,
|
||||||
|
|
@ -568,11 +647,119 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return resolveNearestExistingFrameSlotAssignment({
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
ownerX: args.ownerX,
|
||||||
|
ownerY: args.ownerY,
|
||||||
|
currentFrame: args.currentFrame,
|
||||||
|
frames: strictFrames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNearestRowOrbitSlotAssignment(args: {
|
||||||
|
ownerId: string;
|
||||||
|
ownerX: number;
|
||||||
|
ownerY: number;
|
||||||
|
currentFrame: SlotFrame;
|
||||||
|
ownerFootprints: readonly OwnerFootprint[];
|
||||||
|
snapshot: StableSlotLayoutSnapshot;
|
||||||
|
layout?: GraphLayoutPort;
|
||||||
|
}): NearestSlotAssignmentResult | null {
|
||||||
|
const allowedAssignments = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[args.ownerFootprints.length];
|
||||||
|
if (!allowedAssignments || allowedAssignments.length < args.ownerFootprints.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseAssignments = Object.fromEntries(
|
||||||
|
args.snapshot.memberSlotFrames.map((frame) => [
|
||||||
|
frame.ownerId,
|
||||||
|
{
|
||||||
|
ringIndex: frame.ringIndex,
|
||||||
|
sectorIndex: frame.sectorIndex,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
let best: RankedNearestSlotAssignmentResult | null = null;
|
||||||
|
|
||||||
|
for (const assignment of allowedAssignments) {
|
||||||
|
const occupiedFrame = args.snapshot.memberSlotFrames.find(
|
||||||
|
(frame) =>
|
||||||
|
frame.ownerId !== args.ownerId &&
|
||||||
|
frame.ringIndex === assignment.ringIndex &&
|
||||||
|
frame.sectorIndex === assignment.sectorIndex
|
||||||
|
);
|
||||||
|
const simulatedAssignments: Record<string, GraphOwnerSlotAssignment> = {
|
||||||
|
...baseAssignments,
|
||||||
|
[args.ownerId]: assignment,
|
||||||
|
};
|
||||||
|
if (occupiedFrame) {
|
||||||
|
simulatedAssignments[occupiedFrame.ownerId] = {
|
||||||
|
ringIndex: args.currentFrame.ringIndex,
|
||||||
|
sectorIndex: args.currentFrame.sectorIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const frames = planRowOrbitOwnerSlots(
|
||||||
|
args.ownerFootprints,
|
||||||
|
args.snapshot.centralCollisionRects,
|
||||||
|
args.snapshot.runtimeCentralExclusion,
|
||||||
|
{
|
||||||
|
version: args.layout?.version ?? 'stable-slots-v1',
|
||||||
|
mode: args.layout?.mode ?? 'radial',
|
||||||
|
ownerOrder:
|
||||||
|
args.layout?.ownerOrder ?? args.ownerFootprints.map((footprint) => footprint.ownerId),
|
||||||
|
slotAssignments: simulatedAssignments,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const previewFrame = frames?.find((frame) => frame.ownerId === args.ownerId);
|
||||||
|
if (!previewFrame) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = previewFrame.ownerX - args.ownerX;
|
||||||
|
const dy = previewFrame.ownerY - args.ownerY;
|
||||||
|
const candidate: RankedNearestSlotAssignmentResult = {
|
||||||
|
assignment,
|
||||||
|
displacedOwnerId: occupiedFrame?.ownerId,
|
||||||
|
displacedAssignment: occupiedFrame
|
||||||
|
? {
|
||||||
|
ringIndex: args.currentFrame.ringIndex,
|
||||||
|
sectorIndex: args.currentFrame.sectorIndex,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
previewOwnerX: previewFrame.ownerX,
|
||||||
|
previewOwnerY: previewFrame.ownerY,
|
||||||
|
distanceSquared: dx * dx + dy * dy,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!best || candidate.distanceSquared < best.distanceSquared) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
? {
|
||||||
|
assignment: best.assignment,
|
||||||
|
displacedOwnerId: best.displacedOwnerId,
|
||||||
|
displacedAssignment: best.displacedAssignment,
|
||||||
|
previewOwnerX: best.previewOwnerX,
|
||||||
|
previewOwnerY: best.previewOwnerY,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNearestExistingFrameSlotAssignment(args: {
|
||||||
|
ownerId: string;
|
||||||
|
ownerX: number;
|
||||||
|
ownerY: number;
|
||||||
|
currentFrame: SlotFrame;
|
||||||
|
frames: readonly SlotFrame[];
|
||||||
|
}): NearestSlotAssignmentResult | null {
|
||||||
let best: {
|
let best: {
|
||||||
frame: SlotFrame;
|
frame: SlotFrame;
|
||||||
distanceSquared: number;
|
distanceSquared: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
for (const frame of strictFrames) {
|
for (const frame of args.frames) {
|
||||||
const dx = frame.ownerX - args.ownerX;
|
const dx = frame.ownerX - args.ownerX;
|
||||||
const dy = frame.ownerY - args.ownerY;
|
const dy = frame.ownerY - args.ownerY;
|
||||||
const distanceSquared = dx * dx + dy * dy;
|
const distanceSquared = dx * dx + dy * dy;
|
||||||
|
|
@ -613,7 +800,7 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null {
|
function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null {
|
||||||
if (frames.length === 0 || frames.length > 4) {
|
if (frames.length === 0 || frames.length > 6) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length];
|
const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length];
|
||||||
|
|
@ -968,7 +1155,22 @@ function planOwnerSlots(
|
||||||
centralCollisionRects: readonly StableRect[],
|
centralCollisionRects: readonly StableRect[],
|
||||||
runtimeCentralExclusion: StableRect,
|
runtimeCentralExclusion: StableRect,
|
||||||
layout?: GraphLayoutPort
|
layout?: GraphLayoutPort
|
||||||
): SlotFrame[] {
|
): PlannedMemberSlotLayout {
|
||||||
|
const rowOrbitFrames = shouldUseRowOrbitLayout(ownerFootprints, layout)
|
||||||
|
? planRowOrbitOwnerSlots(
|
||||||
|
ownerFootprints,
|
||||||
|
centralCollisionRects,
|
||||||
|
runtimeCentralExclusion,
|
||||||
|
layout
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (rowOrbitFrames) {
|
||||||
|
return {
|
||||||
|
frames: rowOrbitFrames,
|
||||||
|
kind: 'row-orbit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout)
|
const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout)
|
||||||
? planStrictSmallTeamOwnerSlots(
|
? planStrictSmallTeamOwnerSlots(
|
||||||
ownerFootprints,
|
ownerFootprints,
|
||||||
|
|
@ -978,7 +1180,10 @@ function planOwnerSlots(
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
if (strictSmallTeamFrames) {
|
if (strictSmallTeamFrames) {
|
||||||
return strictSmallTeamFrames;
|
return {
|
||||||
|
frames: strictSmallTeamFrames,
|
||||||
|
kind: 'radial-sector',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const placedFrames: SlotFrame[] = [];
|
const placedFrames: SlotFrame[] = [];
|
||||||
|
|
@ -1002,7 +1207,354 @@ function planOwnerSlots(
|
||||||
commitRingPlacement(ringStates, resolvedFrame, footprint);
|
commitRingPlacement(ringStates, resolvedFrame, footprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
return placedFrames;
|
return {
|
||||||
|
frames: placedFrames,
|
||||||
|
kind: 'radial-sector',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseRowOrbitLayout(
|
||||||
|
ownerFootprints: readonly OwnerFootprint[],
|
||||||
|
layout?: GraphLayoutPort
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
ownerFootprints.length < ROW_ORBIT_MIN_OWNER_COUNT ||
|
||||||
|
ownerFootprints.length > ROW_ORBIT_MAX_OWNER_COUNT
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[ownerFootprints.length];
|
||||||
|
if (!preset || preset.length < ownerFootprints.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length];
|
||||||
|
if (!rowCounts) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const actualAssignments = ownerFootprints
|
||||||
|
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
|
||||||
|
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null);
|
||||||
|
const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments(
|
||||||
|
ownerFootprints.length,
|
||||||
|
actualAssignments
|
||||||
|
);
|
||||||
|
|
||||||
|
const actualAssignmentKeys = actualAssignments
|
||||||
|
.map((assignment) =>
|
||||||
|
normalizeRowOrbitAssignment(assignment, ownerFootprints.length, rowCounts, {
|
||||||
|
useLegacySixTwoRowAssignments,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null)
|
||||||
|
.map((assignment) => buildAssignmentKey(assignment))
|
||||||
|
.sort();
|
||||||
|
const allowedAssignmentKeys = new Set(preset.map((assignment) => buildAssignmentKey(assignment)));
|
||||||
|
|
||||||
|
if (actualAssignmentKeys.length !== ownerFootprints.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueAssignmentKeys = new Set(actualAssignmentKeys);
|
||||||
|
if (uniqueAssignmentKeys.size !== actualAssignmentKeys.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const assignmentKey of actualAssignmentKeys) {
|
||||||
|
if (!allowedAssignmentKeys.has(assignmentKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planRowOrbitOwnerSlots(
|
||||||
|
ownerFootprints: readonly OwnerFootprint[],
|
||||||
|
centralCollisionRects: readonly StableRect[],
|
||||||
|
runtimeCentralExclusion: StableRect,
|
||||||
|
layout?: GraphLayoutPort
|
||||||
|
): SlotFrame[] | null {
|
||||||
|
const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length];
|
||||||
|
if (!rowCounts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotConfigs = buildRowOrbitSlotConfigs(ownerFootprints, rowCounts, layout);
|
||||||
|
if (!slotConfigs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frames = buildRowOrbitSlotFrames(slotConfigs, rowCounts, runtimeCentralExclusion);
|
||||||
|
const allValid = frames.every((frame, frameIndex) =>
|
||||||
|
isSlotFramePlacementValid(
|
||||||
|
frame,
|
||||||
|
frames.filter((_, index) => index !== frameIndex),
|
||||||
|
centralCollisionRects
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return allValid ? frames : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRowOrbitSlotConfigs(
|
||||||
|
ownerFootprints: readonly OwnerFootprint[],
|
||||||
|
rowCounts: readonly number[],
|
||||||
|
layout?: GraphLayoutPort
|
||||||
|
): RowOrbitSlotConfig[] | null {
|
||||||
|
const rowCount = rowCounts.length;
|
||||||
|
const middleRowIndex = rowCount === 3 ? 1 : -1;
|
||||||
|
const configs: RowOrbitSlotConfig[] = [];
|
||||||
|
const actualAssignments = ownerFootprints
|
||||||
|
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
|
||||||
|
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null);
|
||||||
|
const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments(
|
||||||
|
ownerFootprints.length,
|
||||||
|
actualAssignments
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const footprint of ownerFootprints) {
|
||||||
|
const assignment = layout?.slotAssignments?.[footprint.ownerId];
|
||||||
|
if (!assignment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowOrbitAssignment = normalizeRowOrbitAssignment(
|
||||||
|
assignment,
|
||||||
|
ownerFootprints.length,
|
||||||
|
rowCounts,
|
||||||
|
{
|
||||||
|
useLegacySixTwoRowAssignments,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!rowOrbitAssignment) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnCount = rowCounts[rowOrbitAssignment.ringIndex];
|
||||||
|
if (
|
||||||
|
columnCount == null ||
|
||||||
|
rowOrbitAssignment.sectorIndex < 0 ||
|
||||||
|
rowOrbitAssignment.sectorIndex >= columnCount
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
configs.push({
|
||||||
|
footprint,
|
||||||
|
assignment: rowOrbitAssignment,
|
||||||
|
rowIndex: rowOrbitAssignment.ringIndex,
|
||||||
|
columnIndex: rowOrbitAssignment.sectorIndex,
|
||||||
|
columnCount,
|
||||||
|
band: resolveRowOrbitBand(rowOrbitAssignment.ringIndex, rowCount, middleRowIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRowOrbitAssignment(
|
||||||
|
assignment: GraphOwnerSlotAssignment,
|
||||||
|
ownerCount: number,
|
||||||
|
rowCounts: readonly number[],
|
||||||
|
options: { useLegacySixTwoRowAssignments?: boolean } = {}
|
||||||
|
): GraphOwnerSlotAssignment | null {
|
||||||
|
if (
|
||||||
|
options.useLegacySixTwoRowAssignments === true &&
|
||||||
|
ownerCount === 6 &&
|
||||||
|
assignment.ringIndex === 1 &&
|
||||||
|
assignment.sectorIndex >= 0 &&
|
||||||
|
assignment.sectorIndex < 3
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ringIndex: 2,
|
||||||
|
sectorIndex: assignment.sectorIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const directColumnCount = rowCounts[assignment.ringIndex];
|
||||||
|
if (
|
||||||
|
directColumnCount != null &&
|
||||||
|
assignment.sectorIndex >= 0 &&
|
||||||
|
assignment.sectorIndex < directColumnCount
|
||||||
|
) {
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ownerCount === 6 &&
|
||||||
|
assignment.ringIndex === 0 &&
|
||||||
|
assignment.sectorIndex >= 0 &&
|
||||||
|
assignment.sectorIndex < 6
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ringIndex: assignment.sectorIndex < 3 ? 0 : 2,
|
||||||
|
sectorIndex: assignment.sectorIndex % 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldNormalizeLegacySixTwoRowAssignments(
|
||||||
|
ownerCount: number,
|
||||||
|
assignments: readonly GraphOwnerSlotAssignment[]
|
||||||
|
): boolean {
|
||||||
|
if (ownerCount !== 6 || assignments.length !== ownerCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments.some(
|
||||||
|
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRowOrbitBand(
|
||||||
|
rowIndex: number,
|
||||||
|
rowCount: number,
|
||||||
|
middleRowIndex: number
|
||||||
|
): RowOrbitSlotConfig['band'] {
|
||||||
|
if (middleRowIndex >= 0) {
|
||||||
|
if (rowIndex < middleRowIndex) {
|
||||||
|
return 'top';
|
||||||
|
}
|
||||||
|
return rowIndex === middleRowIndex ? 'middle' : 'bottom';
|
||||||
|
}
|
||||||
|
return rowIndex < rowCount / 2 ? 'top' : 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRowOrbitSlotFrames(
|
||||||
|
slotConfigs: readonly RowOrbitSlotConfig[],
|
||||||
|
rowCounts: readonly number[],
|
||||||
|
runtimeCentralExclusion: StableRect
|
||||||
|
): SlotFrame[] {
|
||||||
|
const rowConfigs = groupRowOrbitSlotConfigs(slotConfigs, rowCounts.length);
|
||||||
|
const middleRowIndex = rowCounts.length === 3 ? 1 : -1;
|
||||||
|
const rowTopByIndex = resolveRowOrbitRowTops(rowConfigs, middleRowIndex, runtimeCentralExclusion);
|
||||||
|
const framesByOwnerId = new Map<string, SlotFrame>();
|
||||||
|
const fallbackColumnWidth = Math.max(...slotConfigs.map((config) => config.footprint.slotWidth));
|
||||||
|
|
||||||
|
for (const row of rowConfigs) {
|
||||||
|
if (row.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row[0]?.band === 'middle') {
|
||||||
|
for (const config of row) {
|
||||||
|
const ownerX =
|
||||||
|
config.columnIndex === 0
|
||||||
|
? runtimeCentralExclusion.left - ROW_ORBIT_CENTRAL_GAP - config.footprint.slotWidth / 2
|
||||||
|
: runtimeCentralExclusion.right +
|
||||||
|
ROW_ORBIT_CENTRAL_GAP +
|
||||||
|
config.footprint.slotWidth / 2;
|
||||||
|
framesByOwnerId.set(
|
||||||
|
config.footprint.ownerId,
|
||||||
|
buildSlotFrameAtOwnerAnchor(config.footprint, config.assignment, ownerX, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowTop = rowTopByIndex.get(row[0]!.rowIndex) ?? 0;
|
||||||
|
const columnCount = rowCounts[row[0]!.rowIndex] ?? row.length;
|
||||||
|
const columnWidths = resolveRowOrbitColumnWidths(row, columnCount, fallbackColumnWidth);
|
||||||
|
let nextLeft = -getRowOrbitRowWidth(columnWidths) / 2;
|
||||||
|
for (const config of row) {
|
||||||
|
const ownerX =
|
||||||
|
nextLeft +
|
||||||
|
columnWidths.slice(0, config.columnIndex).reduce((sum, width) => sum + width, 0) +
|
||||||
|
config.columnIndex * ROW_ORBIT_HORIZONTAL_GAP +
|
||||||
|
columnWidths[config.columnIndex]! / 2;
|
||||||
|
const ownerY = rowTop + getOwnerAnchorTopOffset();
|
||||||
|
framesByOwnerId.set(
|
||||||
|
config.footprint.ownerId,
|
||||||
|
buildSlotFrameAtOwnerAnchor(config.footprint, config.assignment, ownerX, ownerY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slotConfigs.flatMap((config) => {
|
||||||
|
const frame = framesByOwnerId.get(config.footprint.ownerId);
|
||||||
|
return frame ? [frame] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRowOrbitSlotConfigs(
|
||||||
|
slotConfigs: readonly RowOrbitSlotConfig[],
|
||||||
|
rowCount: number
|
||||||
|
): RowOrbitSlotConfig[][] {
|
||||||
|
const rows: RowOrbitSlotConfig[][] = Array.from({ length: rowCount }, () => []);
|
||||||
|
for (const config of slotConfigs) {
|
||||||
|
rows[config.rowIndex]!.push(config);
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
row.sort((left, right) => left.columnIndex - right.columnIndex);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRowOrbitRowTops(
|
||||||
|
rowConfigs: readonly (readonly RowOrbitSlotConfig[])[],
|
||||||
|
middleRowIndex: number,
|
||||||
|
runtimeCentralExclusion: StableRect
|
||||||
|
): Map<number, number> {
|
||||||
|
const topByRowIndex = new Map<number, number>();
|
||||||
|
let nextTopRowBottom = runtimeCentralExclusion.top - ROW_ORBIT_CENTRAL_GAP;
|
||||||
|
for (
|
||||||
|
let rowIndex = middleRowIndex >= 0 ? middleRowIndex - 1 : rowConfigs.length / 2 - 1;
|
||||||
|
rowIndex >= 0;
|
||||||
|
rowIndex -= 1
|
||||||
|
) {
|
||||||
|
const row = rowConfigs[rowIndex] ?? [];
|
||||||
|
if (row.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rowHeight = getRowOrbitRowHeight(row);
|
||||||
|
const rowTop = nextTopRowBottom - rowHeight;
|
||||||
|
topByRowIndex.set(rowIndex, rowTop);
|
||||||
|
nextTopRowBottom = rowTop - ROW_ORBIT_VERTICAL_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextBottomRowTop = runtimeCentralExclusion.bottom + ROW_ORBIT_CENTRAL_GAP;
|
||||||
|
for (
|
||||||
|
let rowIndex = middleRowIndex >= 0 ? middleRowIndex + 1 : Math.ceil(rowConfigs.length / 2);
|
||||||
|
rowIndex < rowConfigs.length;
|
||||||
|
rowIndex += 1
|
||||||
|
) {
|
||||||
|
const row = rowConfigs[rowIndex] ?? [];
|
||||||
|
if (row.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
topByRowIndex.set(rowIndex, nextBottomRowTop);
|
||||||
|
nextBottomRowTop += getRowOrbitRowHeight(row) + ROW_ORBIT_VERTICAL_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
return topByRowIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRowOrbitColumnWidths(
|
||||||
|
row: readonly RowOrbitSlotConfig[],
|
||||||
|
columnCount: number,
|
||||||
|
fallbackColumnWidth: number
|
||||||
|
): number[] {
|
||||||
|
const columnWidths = Array.from({ length: columnCount }, () => fallbackColumnWidth);
|
||||||
|
for (const config of row) {
|
||||||
|
columnWidths[config.columnIndex] = Math.max(
|
||||||
|
columnWidths[config.columnIndex] ?? fallbackColumnWidth,
|
||||||
|
config.footprint.slotWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return columnWidths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowOrbitRowWidth(columnWidths: readonly number[]): number {
|
||||||
|
return (
|
||||||
|
columnWidths.reduce((sum, width) => sum + width, 0) +
|
||||||
|
Math.max(0, columnWidths.length - 1) * ROW_ORBIT_HORIZONTAL_GAP
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowOrbitRowHeight(row: readonly RowOrbitSlotConfig[]): number {
|
||||||
|
return Math.max(...row.map((config) => config.footprint.slotHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
function planGridUnderLeadOwnerSlots(
|
function planGridUnderLeadOwnerSlots(
|
||||||
|
|
@ -1012,16 +1564,14 @@ function planGridUnderLeadOwnerSlots(
|
||||||
const frames: SlotFrame[] = [];
|
const frames: SlotFrame[] = [];
|
||||||
const centralBlock = unionRects([...centralCollisionRects]);
|
const centralBlock = unionRects([...centralCollisionRects]);
|
||||||
let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP;
|
let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP;
|
||||||
|
const columnCount = getGridUnderLeadColumnCount(ownerFootprints.length);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let rowStartIndex = 0;
|
let rowStartIndex = 0;
|
||||||
rowStartIndex < ownerFootprints.length;
|
rowStartIndex < ownerFootprints.length;
|
||||||
rowStartIndex += GRID_UNDER_LEAD_COLUMN_COUNT
|
rowStartIndex += columnCount
|
||||||
) {
|
) {
|
||||||
const rowFootprints = ownerFootprints.slice(
|
const rowFootprints = ownerFootprints.slice(rowStartIndex, rowStartIndex + columnCount);
|
||||||
rowStartIndex,
|
|
||||||
rowStartIndex + GRID_UNDER_LEAD_COLUMN_COUNT
|
|
||||||
);
|
|
||||||
const rowWidth =
|
const rowWidth =
|
||||||
rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) +
|
rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) +
|
||||||
Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap;
|
Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap;
|
||||||
|
|
@ -1035,7 +1585,7 @@ function planGridUnderLeadOwnerSlots(
|
||||||
buildSlotFrameAtOwnerAnchor(
|
buildSlotFrameAtOwnerAnchor(
|
||||||
footprint,
|
footprint,
|
||||||
{
|
{
|
||||||
ringIndex: Math.floor(rowStartIndex / GRID_UNDER_LEAD_COLUMN_COUNT),
|
ringIndex: Math.floor(rowStartIndex / columnCount),
|
||||||
sectorIndex: columnIndex,
|
sectorIndex: columnIndex,
|
||||||
},
|
},
|
||||||
ownerX,
|
ownerX,
|
||||||
|
|
@ -1051,11 +1601,17 @@ function planGridUnderLeadOwnerSlots(
|
||||||
return frames;
|
return frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGridUnderLeadColumnCount(ownerCount: number): number {
|
||||||
|
return ownerCount === GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT
|
||||||
|
? GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT
|
||||||
|
: GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldUseStrictSmallTeamCardinalLayout(
|
function shouldUseStrictSmallTeamCardinalLayout(
|
||||||
ownerFootprints: readonly OwnerFootprint[],
|
ownerFootprints: readonly OwnerFootprint[],
|
||||||
layout?: GraphLayoutPort
|
layout?: GraphLayoutPort
|
||||||
): boolean {
|
): boolean {
|
||||||
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) {
|
if (ownerFootprints.length === 0 || ownerFootprints.length > 6) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1090,7 +1646,7 @@ function planStrictSmallTeamOwnerSlots(
|
||||||
runtimeCentralExclusion: StableRect,
|
runtimeCentralExclusion: StableRect,
|
||||||
layout?: GraphLayoutPort
|
layout?: GraphLayoutPort
|
||||||
): SlotFrame[] | null {
|
): SlotFrame[] | null {
|
||||||
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) {
|
if (ownerFootprints.length === 0 || ownerFootprints.length > 6) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1098,13 +1654,16 @@ function planStrictSmallTeamOwnerSlots(
|
||||||
if (!preset || preset.length !== ownerFootprints.length) {
|
if (!preset || preset.length !== ownerFootprints.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const vectorByAssignmentKey = new Map(
|
||||||
|
preset.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
|
||||||
|
);
|
||||||
|
|
||||||
const slotConfigs = ownerFootprints.map((footprint) => {
|
const slotConfigs = ownerFootprints.map((footprint) => {
|
||||||
const assignment = layout?.slotAssignments?.[footprint.ownerId];
|
const assignment = layout?.slotAssignments?.[footprint.ownerId];
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment));
|
const vector = vectorByAssignmentKey.get(buildAssignmentKey(assignment));
|
||||||
if (!vector) {
|
if (!vector) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -1119,72 +1678,164 @@ function planStrictSmallTeamOwnerSlots(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseRadiusByAxis = resolveStrictSmallTeamRadiusByAxis(
|
return packStrictSmallTeamOwnerSlots(
|
||||||
slotConfigs.map((slot) => slot!),
|
slotConfigs.map((slot) => slot!),
|
||||||
centralCollisionRects,
|
centralCollisionRects,
|
||||||
runtimeCentralExclusion
|
runtimeCentralExclusion
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (let iteration = 0; iteration < 48; iteration += 1) {
|
function packStrictSmallTeamOwnerSlots(
|
||||||
const radiusBump = iteration * SMALL_TEAM_CARDINAL_RADIUS_STEP;
|
slotConfigs: readonly {
|
||||||
const frames = slotConfigs.map((slot) => {
|
footprint: OwnerFootprint;
|
||||||
const axis = resolveStrictSmallTeamVectorAxis(slot!.vector);
|
assignment: GraphOwnerSlotAssignment;
|
||||||
return buildSlotFrameAtRadiusWithVector(
|
vector: { x: number; y: number };
|
||||||
slot!.footprint,
|
}[],
|
||||||
slot!.assignment,
|
centralCollisionRects: readonly StableRect[],
|
||||||
baseRadiusByAxis[axis] +
|
runtimeCentralExclusion: StableRect
|
||||||
(axis === 'vertical' ? SMALL_TEAM_CARDINAL_VERTICAL_PADDING : 0) +
|
): SlotFrame[] | null {
|
||||||
radiusBump,
|
const radii = slotConfigs.map((slot) =>
|
||||||
slot!.vector
|
resolveMinimumDirectionalRadiusForVector({
|
||||||
);
|
vector: slot.vector,
|
||||||
});
|
footprint: slot.footprint,
|
||||||
const allValid = frames.every((frame, frameIndex) =>
|
centralCollisionRects,
|
||||||
isSlotFramePlacementValid(
|
runtimeCentralExclusion,
|
||||||
frame,
|
})
|
||||||
frames.filter((_, index) => index !== frameIndex),
|
);
|
||||||
centralCollisionRects
|
|
||||||
)
|
for (let iteration = 0; iteration < STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS; iteration += 1) {
|
||||||
|
const frames = buildStrictSmallTeamFrames(slotConfigs, radii);
|
||||||
|
const invalidCentralIndex = frames.findIndex((frame) =>
|
||||||
|
rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)
|
||||||
);
|
);
|
||||||
if (allValid) {
|
if (invalidCentralIndex >= 0) {
|
||||||
|
radii[invalidCentralIndex] += STRICT_SMALL_TEAM_RADIUS_STEP;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflict = findFirstOwnerSlotFrameConflict(frames);
|
||||||
|
if (!conflict) {
|
||||||
return frames;
|
return frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextLeftRadius = resolveMinimumRadiusAvoidingFrame({
|
||||||
|
slotConfig: slotConfigs[conflict.leftIndex]!,
|
||||||
|
currentRadius: radii[conflict.leftIndex]!,
|
||||||
|
otherFrame: frames[conflict.rightIndex]!,
|
||||||
|
centralCollisionRects,
|
||||||
|
});
|
||||||
|
const nextRightRadius = resolveMinimumRadiusAvoidingFrame({
|
||||||
|
slotConfig: slotConfigs[conflict.rightIndex]!,
|
||||||
|
currentRadius: radii[conflict.rightIndex]!,
|
||||||
|
otherFrame: frames[conflict.leftIndex]!,
|
||||||
|
centralCollisionRects,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextLeftRadius == null && nextRightRadius == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftIncrease =
|
||||||
|
nextLeftRadius == null
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: nextLeftRadius - radii[conflict.leftIndex]!;
|
||||||
|
const rightIncrease =
|
||||||
|
nextRightRadius == null
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: nextRightRadius - radii[conflict.rightIndex]!;
|
||||||
|
|
||||||
|
if (leftIncrease <= rightIncrease) {
|
||||||
|
radii[conflict.leftIndex] = nextLeftRadius!;
|
||||||
|
} else {
|
||||||
|
radii[conflict.rightIndex] = nextRightRadius!;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStrictSmallTeamRadiusByAxis(
|
function buildStrictSmallTeamFrames(
|
||||||
slotConfigs: readonly {
|
slotConfigs: readonly {
|
||||||
footprint: OwnerFootprint;
|
footprint: OwnerFootprint;
|
||||||
|
assignment: GraphOwnerSlotAssignment;
|
||||||
vector: { x: number; y: number };
|
vector: { x: number; y: number };
|
||||||
}[],
|
}[],
|
||||||
centralCollisionRects: readonly StableRect[],
|
radii: readonly number[]
|
||||||
runtimeCentralExclusion: StableRect
|
): SlotFrame[] {
|
||||||
): Record<'horizontal' | 'vertical', number> {
|
return slotConfigs.map((slot, index) =>
|
||||||
const radiusByAxis = {
|
buildSlotFrameAtRadiusWithVector(
|
||||||
horizontal: 0,
|
slot.footprint,
|
||||||
vertical: 0,
|
slot.assignment,
|
||||||
};
|
radii[index] ?? 0,
|
||||||
|
slot.vector
|
||||||
for (const slot of slotConfigs) {
|
)
|
||||||
const axis = resolveStrictSmallTeamVectorAxis(slot.vector);
|
);
|
||||||
const radius = resolveMinimumDirectionalRadiusForVector({
|
|
||||||
vector: slot.vector,
|
|
||||||
footprint: slot.footprint,
|
|
||||||
centralCollisionRects,
|
|
||||||
runtimeCentralExclusion,
|
|
||||||
});
|
|
||||||
radiusByAxis[axis] = Math.max(radiusByAxis[axis], radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
return radiusByAxis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStrictSmallTeamVectorAxis(vector: {
|
function findFirstOwnerSlotFrameConflict(
|
||||||
x: number;
|
frames: readonly SlotFrame[]
|
||||||
y: number;
|
): { leftIndex: number; rightIndex: number } | null {
|
||||||
}): 'horizontal' | 'vertical' {
|
for (const [leftIndex, left] of frames.entries()) {
|
||||||
return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical';
|
for (let rightIndex = leftIndex + 1; rightIndex < frames.length; rightIndex += 1) {
|
||||||
|
const right = frames[rightIndex]!;
|
||||||
|
if (ownerSlotFramesOverlap(left.bounds, right.bounds)) {
|
||||||
|
return { leftIndex, rightIndex };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMinimumRadiusAvoidingFrame(args: {
|
||||||
|
slotConfig: {
|
||||||
|
footprint: OwnerFootprint;
|
||||||
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
vector: { x: number; y: number };
|
||||||
|
};
|
||||||
|
currentRadius: number;
|
||||||
|
otherFrame: SlotFrame;
|
||||||
|
centralCollisionRects: readonly StableRect[];
|
||||||
|
}): number | null {
|
||||||
|
const canPlaceAtRadius = (radius: number): boolean => {
|
||||||
|
const frame = buildSlotFrameAtRadiusWithVector(
|
||||||
|
args.slotConfig.footprint,
|
||||||
|
args.slotConfig.assignment,
|
||||||
|
radius,
|
||||||
|
args.slotConfig.vector
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
!rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects) &&
|
||||||
|
!ownerSlotFramesOverlap(frame.bounds, args.otherFrame.bounds)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (canPlaceAtRadius(args.currentRadius)) {
|
||||||
|
return args.currentRadius;
|
||||||
|
}
|
||||||
|
|
||||||
|
let low = args.currentRadius;
|
||||||
|
let high = Math.max(args.currentRadius + STRICT_SMALL_TEAM_RADIUS_STEP, args.currentRadius * 1.1);
|
||||||
|
let expansionCount = 0;
|
||||||
|
while (!canPlaceAtRadius(high) && expansionCount < 24) {
|
||||||
|
low = high;
|
||||||
|
high = Math.max(high + STRICT_SMALL_TEAM_RADIUS_STEP, high * 1.25);
|
||||||
|
expansionCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canPlaceAtRadius(high)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < 24; iteration += 1) {
|
||||||
|
const mid = (low + high) / 2;
|
||||||
|
if (canPlaceAtRadius(mid)) {
|
||||||
|
high = mid;
|
||||||
|
} else {
|
||||||
|
low = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.ceil(high + STRICT_SMALL_TEAM_RADIUS_EPSILON);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreferredAssignmentsMap(
|
function buildPreferredAssignmentsMap(
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,16 @@ export interface TeamGraphData extends TeamViewSnapshot {
|
||||||
function toGraphLaunchVisualState(
|
function toGraphLaunchVisualState(
|
||||||
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
|
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
|
||||||
): GraphNode['launchVisualState'] {
|
): GraphNode['launchVisualState'] {
|
||||||
return visualState === 'bootstrap_stalled' ? 'runtime_pending' : (visualState ?? undefined);
|
if (!visualState) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (visualState === 'bootstrap_stalled') {
|
||||||
|
return 'runtime_pending';
|
||||||
|
}
|
||||||
|
if (visualState === 'starting_stale') {
|
||||||
|
return 'spawning';
|
||||||
|
}
|
||||||
|
return visualState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamGraphAdapter {
|
export class TeamGraphAdapter {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export const RecentProjectCard = ({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised"
|
className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised"
|
||||||
style={{
|
style={{
|
||||||
borderLeftColor: color.border,
|
borderLeftColor: color.border,
|
||||||
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
|
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||||
|
|
||||||
// Keep userData stable before any integration can initialize Electron storage.
|
// Keep userData stable before any integration can initialize Electron storage.
|
||||||
// Sentry must stay near the top to capture early errors after storage migration.
|
// Sentry must stay near the top to capture early errors after storage migration.
|
||||||
|
// eslint-disable-next-line simple-import-sort/imports -- userData migration must run before Sentry initializes Electron storage.
|
||||||
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||||
import './sentry';
|
import './sentry';
|
||||||
|
|
||||||
|
|
@ -76,8 +77,9 @@ import {
|
||||||
} from '@main/services/team/TeamMcpConfigBuilder';
|
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||||
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
|
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
|
||||||
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
|
||||||
import {
|
import {
|
||||||
|
APP_STARTUP_GET_STATUS,
|
||||||
|
APP_STARTUP_PROGRESS,
|
||||||
CONTEXT_CHANGED,
|
CONTEXT_CHANGED,
|
||||||
SCHEDULE_CHANGE,
|
SCHEDULE_CHANGE,
|
||||||
SKILLS_CHANGED,
|
SKILLS_CHANGED,
|
||||||
|
|
@ -105,6 +107,7 @@ import { join } from 'path';
|
||||||
|
|
||||||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||||
|
import { registerRendererLogHandlers } from './ipc/rendererLogs';
|
||||||
import { setReviewMainWindow } from './ipc/review';
|
import { setReviewMainWindow } from './ipc/review';
|
||||||
import { setTmuxMainWindow } from './ipc/tmux';
|
import { setTmuxMainWindow } from './ipc/tmux';
|
||||||
import {
|
import {
|
||||||
|
|
@ -209,7 +212,7 @@ import {
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
import type { FileChangeEvent } from '@main/types';
|
import type { FileChangeEvent } from '@main/types';
|
||||||
import type { TeamChangeEvent } from '@shared/types';
|
import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('App');
|
const logger = createLogger('App');
|
||||||
let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null;
|
let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null;
|
||||||
|
|
@ -256,17 +259,27 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500;
|
||||||
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
|
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
|
||||||
const suppressedSources = new Set(['user_sent']);
|
const suppressedSources = new Set(['user_sent']);
|
||||||
|
|
||||||
async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapterRegistry> {
|
async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
reportProgress: (phase: string, message: string) => void = () => undefined
|
||||||
|
): Promise<TeamRuntimeAdapterRegistry> {
|
||||||
|
const binaryPath = await ClaudeBinaryResolver.resolve({
|
||||||
|
onProgress: ({ phase, message }) => reportProgress(`runtime-${phase}`, message),
|
||||||
|
});
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved');
|
logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved');
|
||||||
|
reportProgress(
|
||||||
|
'runtime-unavailable',
|
||||||
|
'Runtime not found. Continuing with limited launch support...'
|
||||||
|
);
|
||||||
openCodeLifecycleBridge = null;
|
openCodeLifecycleBridge = null;
|
||||||
return new TeamRuntimeAdapterRegistry();
|
return new TeamRuntimeAdapterRegistry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportProgress('runtime-environment', 'Preparing runtime environment...');
|
||||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||||
try {
|
try {
|
||||||
|
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
||||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
|
|
@ -282,7 +295,10 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...');
|
||||||
|
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({
|
||||||
|
onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message),
|
||||||
|
});
|
||||||
const mcpEntry = mcpLaunchSpec.args[0];
|
const mcpEntry = mcpLaunchSpec.args[0];
|
||||||
if (mcpEntry) {
|
if (mcpEntry) {
|
||||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
||||||
|
|
@ -297,6 +313,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||||
const bridgeClient = new OpenCodeBridgeCommandClient({
|
const bridgeClient = new OpenCodeBridgeCommandClient({
|
||||||
binaryPath,
|
binaryPath,
|
||||||
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
|
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
|
||||||
|
|
@ -624,6 +641,11 @@ let teamBackupService: TeamBackupService | null = null;
|
||||||
let branchStatusService: BranchStatusService | null = null;
|
let branchStatusService: BranchStatusService | null = null;
|
||||||
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let rendererRecoveryAttempts = 0;
|
let rendererRecoveryAttempts = 0;
|
||||||
|
let servicesReady = false;
|
||||||
|
let rendererDidFinishLoad = false;
|
||||||
|
let fileWatcherStartupStarted = false;
|
||||||
|
let backgroundStartupTasksStarted = false;
|
||||||
|
let appStartupHandlersRegistered = false;
|
||||||
|
|
||||||
// File watcher event cleanup functions
|
// File watcher event cleanup functions
|
||||||
let fileChangeCleanup: (() => void) | null = null;
|
let fileChangeCleanup: (() => void) | null = null;
|
||||||
|
|
@ -636,6 +658,24 @@ const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||||
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
||||||
const STARTUP_RECOVERY_DELAY_MS = 10_000;
|
const STARTUP_RECOVERY_DELAY_MS = 10_000;
|
||||||
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||||
|
const appStartupStartedAt = Date.now();
|
||||||
|
let appStartupSteps: AppStartupStep[] = [
|
||||||
|
{
|
||||||
|
phase: 'boot',
|
||||||
|
message: 'Starting Agent Teams AI...',
|
||||||
|
startedAt: appStartupStartedAt,
|
||||||
|
updatedAt: appStartupStartedAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let appStartupStatus: AppStartupStatus = {
|
||||||
|
phase: 'boot',
|
||||||
|
message: 'Starting Agent Teams AI...',
|
||||||
|
ready: false,
|
||||||
|
error: null,
|
||||||
|
startedAt: appStartupStartedAt,
|
||||||
|
updatedAt: appStartupStartedAt,
|
||||||
|
steps: appStartupSteps,
|
||||||
|
};
|
||||||
|
|
||||||
function isShutdownStarted(): boolean {
|
function isShutdownStarted(): boolean {
|
||||||
return shutdownComplete || shutdownPromise !== null;
|
return shutdownComplete || shutdownPromise !== null;
|
||||||
|
|
@ -653,6 +693,74 @@ function scheduleStartupTask(action: () => void, delayMs: number): void {
|
||||||
startupTimers.add(timer);
|
startupTimers.add(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerAppStartupHandlers(): void {
|
||||||
|
if (appStartupHandlersRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appStartupHandlersRegistered = true;
|
||||||
|
registerRendererLogHandlers(ipcMain);
|
||||||
|
ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneStartupSteps(): AppStartupStep[] {
|
||||||
|
return appStartupSteps.map((step) => ({ ...step }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number): void {
|
||||||
|
if (!update.phase && !update.message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = update.phase ?? appStartupStatus.phase;
|
||||||
|
const message = update.message ?? appStartupStatus.message;
|
||||||
|
const current = appStartupSteps[appStartupSteps.length - 1];
|
||||||
|
|
||||||
|
if (current?.phase !== phase) {
|
||||||
|
if (current && !current.finishedAt) {
|
||||||
|
current.finishedAt = now;
|
||||||
|
current.durationMs = now - current.startedAt;
|
||||||
|
current.updatedAt = now;
|
||||||
|
}
|
||||||
|
appStartupSteps.push({
|
||||||
|
phase,
|
||||||
|
message,
|
||||||
|
startedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
if (appStartupSteps.length > 32) {
|
||||||
|
appStartupSteps = appStartupSteps.slice(-32);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.message = message;
|
||||||
|
current.updatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishCurrentStartupStep(now: number): void {
|
||||||
|
const current = appStartupSteps[appStartupSteps.length - 1];
|
||||||
|
if (!current || current.finishedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.finishedAt = now;
|
||||||
|
current.durationMs = now - current.startedAt;
|
||||||
|
current.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishStartupStatus(update: Partial<AppStartupStatus>): void {
|
||||||
|
const now = Date.now();
|
||||||
|
updateStartupTimeline(update, now);
|
||||||
|
if (update.ready === true || update.error) {
|
||||||
|
finishCurrentStartupStep(now);
|
||||||
|
}
|
||||||
|
appStartupStatus = {
|
||||||
|
...appStartupStatus,
|
||||||
|
...update,
|
||||||
|
updatedAt: now,
|
||||||
|
steps: cloneStartupSteps(),
|
||||||
|
};
|
||||||
|
safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus);
|
||||||
|
}
|
||||||
|
|
||||||
async function runStartupJobsBounded<T>(
|
async function runStartupJobsBounded<T>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
concurrency: number,
|
concurrency: number,
|
||||||
|
|
@ -1063,6 +1171,12 @@ function reconfigureLocalContextForClaudeRoot(): void {
|
||||||
*/
|
*/
|
||||||
async function initializeServices(): Promise<void> {
|
async function initializeServices(): Promise<void> {
|
||||||
logger.info('Initializing services...');
|
logger.info('Initializing services...');
|
||||||
|
publishStartupStatus({
|
||||||
|
phase: 'services',
|
||||||
|
message: 'Preparing app services...',
|
||||||
|
ready: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize SSH connection manager
|
// Initialize SSH connection manager
|
||||||
sshConnectionManager = new SshConnectionManager();
|
sshConnectionManager = new SshConnectionManager();
|
||||||
|
|
@ -1167,10 +1281,20 @@ async function initializeServices(): Promise<void> {
|
||||||
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
|
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
|
||||||
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
|
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
|
||||||
});
|
});
|
||||||
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
|
publishStartupStatus({
|
||||||
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
|
phase: 'runtime',
|
||||||
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
|
message: 'Resolving local runtime...',
|
||||||
|
});
|
||||||
|
teamProvisioningService.setRuntimeAdapterRegistry(
|
||||||
|
await createOpenCodeRuntimeAdapterRegistry((phase, message) =>
|
||||||
|
publishStartupStatus({ phase, message })
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
scheduleStartupTask(() => {
|
||||||
|
void cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
|
||||||
|
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
|
||||||
|
);
|
||||||
|
}, STARTUP_RECOVERY_DELAY_MS);
|
||||||
// Startup GC: remove stale MCP config files from previous sessions (best-effort)
|
// Startup GC: remove stale MCP config files from previous sessions (best-effort)
|
||||||
void new TeamMcpConfigBuilder().gcStaleConfigs();
|
void new TeamMcpConfigBuilder().gcStaleConfigs();
|
||||||
void teamDataService
|
void teamDataService
|
||||||
|
|
@ -1267,6 +1391,10 @@ async function initializeServices(): Promise<void> {
|
||||||
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
|
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
providerConnectionService.setApiKeyService(apiKeyService);
|
providerConnectionService.setApiKeyService(apiKeyService);
|
||||||
|
publishStartupStatus({
|
||||||
|
phase: 'settings',
|
||||||
|
message: 'Loading secure settings...',
|
||||||
|
});
|
||||||
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||||
// warmup() and ensureInstalled() are deferred to after window creation
|
// warmup() and ensureInstalled() are deferred to after window creation
|
||||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||||
|
|
@ -1448,6 +1576,11 @@ async function initializeServices(): Promise<void> {
|
||||||
// startProcessHealthPolling() is deferred to after window creation
|
// startProcessHealthPolling() is deferred to after window creation
|
||||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||||
|
|
||||||
|
publishStartupStatus({
|
||||||
|
phase: 'ipc',
|
||||||
|
message: 'Wiring app actions...',
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize IPC handlers with registry
|
// Initialize IPC handlers with registry
|
||||||
initializeIpcHandlers(
|
initializeIpcHandlers(
|
||||||
contextRegistry,
|
contextRegistry,
|
||||||
|
|
@ -1529,6 +1662,10 @@ async function initializeServices(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Services initialized successfully');
|
logger.info('Services initialized successfully');
|
||||||
|
publishStartupStatus({
|
||||||
|
phase: 'readying',
|
||||||
|
message: 'Finishing startup...',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1717,6 +1854,85 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
|
||||||
safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor);
|
safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachMainWindowToServices(): void {
|
||||||
|
const win = mainWindow;
|
||||||
|
if (!win || win.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationManager?.setMainWindow(win);
|
||||||
|
updaterService?.setMainWindow(win);
|
||||||
|
cliInstallerService?.setMainWindow(win);
|
||||||
|
setTmuxMainWindow(win);
|
||||||
|
ptyTerminalService?.setMainWindow(win);
|
||||||
|
teamProvisioningService?.setMainWindow(win);
|
||||||
|
codexAccountFeature?.setMainWindow(win);
|
||||||
|
setEditorMainWindow(win);
|
||||||
|
setReviewMainWindow(win);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPostRendererStartupTasks(): void {
|
||||||
|
if (!servicesReady || !rendererDidFinishLoad || !mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileWatcherStartupStarted) {
|
||||||
|
fileWatcherStartupStarted = true;
|
||||||
|
// Start file watchers after both the visible window and main services are ready.
|
||||||
|
const activeContext = contextRegistry.getActive();
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
scheduleStartupTask(() => {
|
||||||
|
if (!fileWatcherStartupStarted || !servicesReady || !rendererDidFinishLoad) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeContext.startFileWatcher();
|
||||||
|
}, 1500);
|
||||||
|
} else if (!isShutdownStarted()) {
|
||||||
|
activeContext.startFileWatcher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backgroundStartupTasksStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backgroundStartupTasksStarted = true;
|
||||||
|
|
||||||
|
if (!isShutdownStarted()) {
|
||||||
|
scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000);
|
||||||
|
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleStartupTask(
|
||||||
|
() => {
|
||||||
|
void getTeamFsWorkerClient()
|
||||||
|
.prewarm()
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
logger.debug(
|
||||||
|
`[startup] team-fs-worker prewarm skipped: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
void getTeamDataWorkerClient()
|
||||||
|
.prewarm()
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
logger.debug(
|
||||||
|
`[startup] team-data-worker prewarm skipped: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
process.platform === 'win32' ? 2500 : 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleStartupTask(() => {
|
||||||
|
void teamProvisioningService.warmup();
|
||||||
|
teamDataService.startProcessHealthPolling();
|
||||||
|
void schedulerService?.start();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleRendererRecovery(win: BrowserWindow): void {
|
function scheduleRendererRecovery(win: BrowserWindow): void {
|
||||||
if (isShutdownStarted()) {
|
if (isShutdownStarted()) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1759,6 +1975,7 @@ function createWindow(): void {
|
||||||
if (isShutdownStarted()) {
|
if (isShutdownStarted()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rendererDidFinishLoad = false;
|
||||||
|
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
|
@ -1780,7 +1997,7 @@ function createWindow(): void {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
||||||
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
||||||
title: 'Agent Teams UI',
|
title: 'Agent Teams AI',
|
||||||
});
|
});
|
||||||
markRendererUnavailable(mainWindow);
|
markRendererUnavailable(mainWindow);
|
||||||
|
|
||||||
|
|
@ -1850,6 +2067,7 @@ function createWindow(): void {
|
||||||
if (isShutdownStarted()) {
|
if (isShutdownStarted()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rendererDidFinishLoad = false;
|
||||||
markRendererUnavailable(mainWindow);
|
markRendererUnavailable(mainWindow);
|
||||||
branchStatusService?.resetAllTracking();
|
branchStatusService?.resetAllTracking();
|
||||||
});
|
});
|
||||||
|
|
@ -1874,57 +2092,8 @@ function createWindow(): void {
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
fullscreenSyncTimer.unref?.();
|
fullscreenSyncTimer.unref?.();
|
||||||
// Start file watchers now that the window is visible and responsive.
|
rendererDidFinishLoad = true;
|
||||||
// Deferred from initializeServices() to avoid blocking window creation
|
runPostRendererStartupTasks();
|
||||||
// with fs.watch() setup (especially slow on Windows with recursive watchers).
|
|
||||||
const activeContext = contextRegistry.getActive();
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
// On Windows, delay FileWatcher startup to let the renderer complete
|
|
||||||
// its initial IPC calls without UV thread pool contention. Recursive
|
|
||||||
// fs.watch() on NTFS saturates all 4 default UV threads.
|
|
||||||
scheduleStartupTask(() => activeContext.startFileWatcher(), 1500);
|
|
||||||
} else {
|
|
||||||
if (!isShutdownStarted()) {
|
|
||||||
activeContext.startFileWatcher();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isShutdownStarted()) {
|
|
||||||
scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000);
|
|
||||||
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleStartupTask(
|
|
||||||
() => {
|
|
||||||
void getTeamFsWorkerClient()
|
|
||||||
.prewarm()
|
|
||||||
.catch((error: unknown) =>
|
|
||||||
logger.debug(
|
|
||||||
`[startup] team-fs-worker prewarm skipped: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
void getTeamDataWorkerClient()
|
|
||||||
.prewarm()
|
|
||||||
.catch((error: unknown) =>
|
|
||||||
logger.debug(
|
|
||||||
`[startup] team-data-worker prewarm skipped: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
process.platform === 'win32' ? 2500 : 1000
|
|
||||||
);
|
|
||||||
|
|
||||||
// Defer non-critical startup work to avoid thread pool contention.
|
|
||||||
// The window is now visible and responsive; these run in the background.
|
|
||||||
scheduleStartupTask(() => {
|
|
||||||
void teamProvisioningService.warmup();
|
|
||||||
teamDataService.startProcessHealthPolling();
|
|
||||||
void schedulerService?.start();
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2037,34 +2206,16 @@ function createWindow(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
markRendererUnavailable(mainWindow);
|
markRendererUnavailable(mainWindow);
|
||||||
|
rendererDidFinishLoad = false;
|
||||||
|
fileWatcherStartupStarted = false;
|
||||||
branchStatusService?.resetAllTracking();
|
branchStatusService?.resetAllTracking();
|
||||||
const activeContext = contextRegistry.getActive();
|
contextRegistry?.getActive()?.stopFileWatcher();
|
||||||
activeContext?.stopFileWatcher();
|
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
scheduleRendererRecovery(mainWindow);
|
scheduleRendererRecovery(mainWindow);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set main window reference for notification manager and updater
|
attachMainWindowToServices();
|
||||||
if (notificationManager) {
|
|
||||||
notificationManager.setMainWindow(mainWindow);
|
|
||||||
}
|
|
||||||
if (updaterService) {
|
|
||||||
updaterService.setMainWindow(mainWindow);
|
|
||||||
}
|
|
||||||
if (cliInstallerService) {
|
|
||||||
cliInstallerService.setMainWindow(mainWindow);
|
|
||||||
}
|
|
||||||
setTmuxMainWindow(mainWindow);
|
|
||||||
if (ptyTerminalService) {
|
|
||||||
ptyTerminalService.setMainWindow(mainWindow);
|
|
||||||
}
|
|
||||||
if (teamProvisioningService) {
|
|
||||||
teamProvisioningService.setMainWindow(mainWindow);
|
|
||||||
}
|
|
||||||
codexAccountFeature?.setMainWindow(mainWindow);
|
|
||||||
setEditorMainWindow(mainWindow);
|
|
||||||
setReviewMainWindow(mainWindow);
|
|
||||||
|
|
||||||
logger.info('Main window created');
|
logger.info('Main window created');
|
||||||
}
|
}
|
||||||
|
|
@ -2074,18 +2225,14 @@ function createWindow(): void {
|
||||||
*/
|
*/
|
||||||
void app.whenReady().then(async () => {
|
void app.whenReady().then(async () => {
|
||||||
logger.info('App ready, initializing...');
|
logger.info('App ready, initializing...');
|
||||||
|
registerAppStartupHandlers();
|
||||||
// Pre-warm interactive shell env cache (non-blocking).
|
|
||||||
// On macOS, Finder-launched apps get a minimal PATH. This resolves the user's
|
|
||||||
// full shell PATH (nvm, homebrew, .local/bin, etc.) in the background so that
|
|
||||||
// CliInstallerService.getStatus() and other services get cached results instantly.
|
|
||||||
void resolveInteractiveShellEnv();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize services first
|
publishStartupStatus({
|
||||||
await initializeServices();
|
phase: 'electron-ready',
|
||||||
|
message: 'Opening window...',
|
||||||
|
});
|
||||||
|
|
||||||
// Apply configuration settings
|
|
||||||
const config = configManager.getConfig();
|
const config = configManager.getConfig();
|
||||||
|
|
||||||
// Sync Sentry telemetry opt-in flag from persisted config
|
// Sync Sentry telemetry opt-in flag from persisted config
|
||||||
|
|
@ -2109,9 +2256,19 @@ void app.whenReady().then(async () => {
|
||||||
// so we avoid runtime setIcon calls that can fail and block startup.
|
// so we avoid runtime setIcon calls that can fail and block startup.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then create window
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
|
await initializeServices();
|
||||||
|
servicesReady = true;
|
||||||
|
attachMainWindowToServices();
|
||||||
|
publishStartupStatus({
|
||||||
|
phase: 'ready',
|
||||||
|
message: 'Ready',
|
||||||
|
ready: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
runPostRendererStartupTasks();
|
||||||
|
|
||||||
// Listen for notification click events
|
// Listen for notification click events
|
||||||
notificationManager.on('notification-clicked', (_error) => {
|
notificationManager.on('notification-clicked', (_error) => {
|
||||||
if (isShutdownStarted()) {
|
if (isShutdownStarted()) {
|
||||||
|
|
@ -2124,6 +2281,12 @@ void app.whenReady().then(async () => {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Startup initialization failed:', error);
|
logger.error('Startup initialization failed:', error);
|
||||||
|
publishStartupStatus({
|
||||||
|
phase: 'failed',
|
||||||
|
message: 'Startup failed',
|
||||||
|
ready: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
if (!mainWindow) {
|
if (!mainWindow) {
|
||||||
createWindow();
|
createWindow();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
|
||||||
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
|
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
|
||||||
let heartbeatMonitorStarted = false;
|
let heartbeatMonitorStarted = false;
|
||||||
let heartbeatMonitorInterval: ReturnType<typeof setInterval> | null = null;
|
let heartbeatMonitorInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let rendererLogHandlersRegistered = false;
|
||||||
|
|
||||||
function startHeartbeatMonitor(): void {
|
function startHeartbeatMonitor(): void {
|
||||||
if (heartbeatMonitorStarted) return;
|
if (heartbeatMonitorStarted) return;
|
||||||
|
|
@ -40,6 +41,10 @@ function startHeartbeatMonitor(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerRendererLogHandlers(ipcMain: IpcMain): void {
|
export function registerRendererLogHandlers(ipcMain: IpcMain): void {
|
||||||
|
if (rendererLogHandlersRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rendererLogHandlersRegistered = true;
|
||||||
startHeartbeatMonitor();
|
startHeartbeatMonitor();
|
||||||
|
|
||||||
ipcMain.on(RENDERER_LOG, () => {
|
ipcMain.on(RENDERER_LOG, () => {
|
||||||
|
|
@ -69,6 +74,7 @@ export function removeRendererLogHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeAllListeners(RENDERER_LOG);
|
ipcMain.removeAllListeners(RENDERER_LOG);
|
||||||
ipcMain.removeAllListeners(RENDERER_BOOT);
|
ipcMain.removeAllListeners(RENDERER_BOOT);
|
||||||
ipcMain.removeAllListeners(RENDERER_HEARTBEAT);
|
ipcMain.removeAllListeners(RENDERER_HEARTBEAT);
|
||||||
|
rendererLogHandlersRegistered = false;
|
||||||
|
|
||||||
if (heartbeatMonitorInterval) {
|
if (heartbeatMonitorInterval) {
|
||||||
clearInterval(heartbeatMonitorInterval);
|
clearInterval(heartbeatMonitorInterval);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,23 @@ import * as path from 'path';
|
||||||
import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe';
|
import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe';
|
||||||
import { getConfiguredCliFlavor } from './cliFlavor';
|
import { getConfiguredCliFlavor } from './cliFlavor';
|
||||||
|
|
||||||
|
export interface ClaudeBinaryResolveProgress {
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaudeBinaryResolveOptions {
|
||||||
|
onProgress?: (progress: ClaudeBinaryResolveProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitProgress(
|
||||||
|
options: ClaudeBinaryResolveOptions | undefined,
|
||||||
|
phase: string,
|
||||||
|
message: string
|
||||||
|
): void {
|
||||||
|
options?.onProgress?.({ phase, message });
|
||||||
|
}
|
||||||
|
|
||||||
async function isExecutable(filePath: string): Promise<boolean> {
|
async function isExecutable(filePath: string): Promise<boolean> {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -218,34 +235,46 @@ export class ClaudeBinaryResolver {
|
||||||
cacheVerifiedAt = 0;
|
cacheVerifiedAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async resolve(): Promise<string | null> {
|
static async resolve(options: ClaudeBinaryResolveOptions = {}): Promise<string | null> {
|
||||||
if (cachedPath !== undefined) {
|
if (cachedPath !== undefined) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Re-verify the cached binary still exists, but at most once per TTL
|
// Re-verify the cached binary still exists, but at most once per TTL
|
||||||
if (cachedPath !== null && now - cacheVerifiedAt > CACHE_VERIFY_TTL_MS) {
|
if (cachedPath !== null && now - cacheVerifiedAt > CACHE_VERIFY_TTL_MS) {
|
||||||
|
emitProgress(options, 'cache-verify', 'Verifying cached runtime...');
|
||||||
if (await isExecutable(cachedPath)) {
|
if (await isExecutable(cachedPath)) {
|
||||||
cacheVerifiedAt = now;
|
cacheVerifiedAt = now;
|
||||||
|
emitProgress(options, 'cache-hit', 'Using cached runtime...');
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
cachedPath = undefined;
|
cachedPath = undefined;
|
||||||
cacheVerifiedAt = 0;
|
cacheVerifiedAt = 0;
|
||||||
// Fall through to full resolution below
|
// Fall through to full resolution below
|
||||||
} else {
|
} else {
|
||||||
|
emitProgress(
|
||||||
|
options,
|
||||||
|
cachedPath ? 'cache-hit' : 'cache-miss',
|
||||||
|
'Using cached runtime status...'
|
||||||
|
);
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!resolveInFlight) {
|
if (!resolveInFlight) {
|
||||||
resolveInFlight = ClaudeBinaryResolver.runResolve().finally(() => {
|
resolveInFlight = ClaudeBinaryResolver.runResolve(options).finally(() => {
|
||||||
resolveInFlight = null;
|
resolveInFlight = null;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
emitProgress(options, 'in-flight', 'Waiting for runtime lookup...');
|
||||||
}
|
}
|
||||||
return resolveInFlight;
|
return resolveInFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async runResolve(): Promise<string | null> {
|
private static async runResolve(options: ClaudeBinaryResolveOptions): Promise<string | null> {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnv({
|
||||||
|
onProgress: (progress) => emitProgress(options, progress.phase, progress.message),
|
||||||
|
});
|
||||||
const enrichedPath = buildMergedCliPath(null);
|
const enrichedPath = buildMergedCliPath(null);
|
||||||
const flavor = getConfiguredCliFlavor();
|
const flavor = getConfiguredCliFlavor();
|
||||||
|
emitProgress(options, 'flavor', `Using ${flavor} runtime mode...`);
|
||||||
|
|
||||||
const overrideRaw =
|
const overrideRaw =
|
||||||
flavor === 'agent_teams_orchestrator'
|
flavor === 'agent_teams_orchestrator'
|
||||||
|
|
@ -253,6 +282,7 @@ export class ClaudeBinaryResolver {
|
||||||
process.env.CLAUDE_CLI_PATH?.trim())
|
process.env.CLAUDE_CLI_PATH?.trim())
|
||||||
: process.env.CLAUDE_CLI_PATH?.trim();
|
: process.env.CLAUDE_CLI_PATH?.trim();
|
||||||
if (overrideRaw) {
|
if (overrideRaw) {
|
||||||
|
emitProgress(options, 'configured-path', 'Checking configured runtime path...');
|
||||||
const looksLikePath =
|
const looksLikePath =
|
||||||
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
|
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
|
||||||
const resolvedOverride = looksLikePath
|
const resolvedOverride = looksLikePath
|
||||||
|
|
@ -262,15 +292,18 @@ export class ClaudeBinaryResolver {
|
||||||
if (resolvedOverride) {
|
if (resolvedOverride) {
|
||||||
cachedPath = resolvedOverride;
|
cachedPath = resolvedOverride;
|
||||||
cacheVerifiedAt = Date.now();
|
cacheVerifiedAt = Date.now();
|
||||||
|
emitProgress(options, 'configured-path-found', 'Using configured runtime path...');
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flavor === 'agent_teams_orchestrator') {
|
if (flavor === 'agent_teams_orchestrator') {
|
||||||
|
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
|
||||||
const bundledBinary = await resolveBundledOrchestratorBinary();
|
const bundledBinary = await resolveBundledOrchestratorBinary();
|
||||||
if (bundledBinary) {
|
if (bundledBinary) {
|
||||||
cachedPath = bundledBinary;
|
cachedPath = bundledBinary;
|
||||||
cacheVerifiedAt = Date.now();
|
cacheVerifiedAt = Date.now();
|
||||||
|
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,17 +312,21 @@ export class ClaudeBinaryResolver {
|
||||||
// claude-multimodel on PATH without making this resolver guess a sibling
|
// claude-multimodel on PATH without making this resolver guess a sibling
|
||||||
// repo name or folder.
|
// repo name or folder.
|
||||||
const orchestratorBinaryName = 'claude-multimodel';
|
const orchestratorBinaryName = 'claude-multimodel';
|
||||||
|
emitProgress(options, 'path-runtime', 'Searching PATH for Agent Teams runtime...');
|
||||||
const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath);
|
const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath);
|
||||||
if (fromPath) {
|
if (fromPath) {
|
||||||
cachedPath = fromPath;
|
cachedPath = fromPath;
|
||||||
cacheVerifiedAt = Date.now();
|
cacheVerifiedAt = Date.now();
|
||||||
|
emitProgress(options, 'path-runtime-found', 'Using Agent Teams runtime from PATH...');
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitProgress(options, 'doctor-runtime', 'Checking runtime diagnostics fallback...');
|
||||||
const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName);
|
const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName);
|
||||||
if (fromDoctor) {
|
if (fromDoctor) {
|
||||||
cachedPath = fromDoctor;
|
cachedPath = fromDoctor;
|
||||||
cacheVerifiedAt = Date.now();
|
cacheVerifiedAt = Date.now();
|
||||||
|
emitProgress(options, 'doctor-runtime-found', 'Using runtime from diagnostics fallback...');
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,10 +337,12 @@ export class ClaudeBinaryResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseBinaryName = 'claude';
|
const baseBinaryName = 'claude';
|
||||||
|
emitProgress(options, 'path-claude', 'Searching PATH for Claude CLI...');
|
||||||
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
|
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
|
||||||
if (fromPath) {
|
if (fromPath) {
|
||||||
cachedPath = fromPath;
|
cachedPath = fromPath;
|
||||||
cacheVerifiedAt = Date.now();
|
cacheVerifiedAt = Date.now();
|
||||||
|
emitProgress(options, 'path-claude-found', 'Using Claude CLI from PATH...');
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -343,7 +382,11 @@ export class ClaudeBinaryResolver {
|
||||||
platformBinaryNames.map((name) => path.join(dir, name))
|
platformBinaryNames.map((name) => path.join(dir, name))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
emitProgress(options, 'standard-locations', 'Checking standard Claude install locations...');
|
||||||
const nvmCandidates = await collectNvmCandidates();
|
const nvmCandidates = await collectNvmCandidates();
|
||||||
|
if (nvmCandidates.length > 0) {
|
||||||
|
emitProgress(options, 'nvm-locations', 'Checking nvm-managed Claude installs...');
|
||||||
|
}
|
||||||
const allCandidates = [...candidates, ...nvmCandidates];
|
const allCandidates = [...candidates, ...nvmCandidates];
|
||||||
|
|
||||||
// Check all fallback candidates in parallel for speed
|
// Check all fallback candidates in parallel for speed
|
||||||
|
|
@ -358,17 +401,29 @@ export class ClaudeBinaryResolver {
|
||||||
if (found) {
|
if (found) {
|
||||||
cachedPath = found.path;
|
cachedPath = found.path;
|
||||||
cacheVerifiedAt = Date.now();
|
cacheVerifiedAt = Date.now();
|
||||||
|
emitProgress(
|
||||||
|
options,
|
||||||
|
'fallback-location-found',
|
||||||
|
'Using Claude CLI from install locations...'
|
||||||
|
);
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitProgress(options, 'doctor-claude', 'Checking Claude diagnostics fallback...');
|
||||||
const fromDoctor = await resolveFromDoctorFallback(baseBinaryName);
|
const fromDoctor = await resolveFromDoctorFallback(baseBinaryName);
|
||||||
if (fromDoctor) {
|
if (fromDoctor) {
|
||||||
cachedPath = fromDoctor;
|
cachedPath = fromDoctor;
|
||||||
cacheVerifiedAt = Date.now();
|
cacheVerifiedAt = Date.now();
|
||||||
|
emitProgress(options, 'doctor-claude-found', 'Using Claude CLI from diagnostics fallback...');
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't cache null — CLI may be installed later without app restart
|
// Don't cache null — CLI may be installed later without app restart
|
||||||
|
emitProgress(
|
||||||
|
options,
|
||||||
|
'not-found',
|
||||||
|
'Runtime not found. Continuing with limited launch support...'
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,15 +203,27 @@ export function summarizeProcessBootstrapTransportEvents(
|
||||||
export function buildProcessBootstrapPendingDiagnostic(
|
export function buildProcessBootstrapPendingDiagnostic(
|
||||||
summary: ProcessBootstrapTransportSummary
|
summary: ProcessBootstrapTransportSummary
|
||||||
): string {
|
): string {
|
||||||
|
if (summary.submitted) {
|
||||||
|
return summary.lastStage
|
||||||
|
? `Bootstrap prompt was submitted; waiting for bootstrap confirmation. Last transport stage: ${summary.lastStage}.`
|
||||||
|
: 'Bootstrap prompt was submitted; waiting for bootstrap confirmation.';
|
||||||
|
}
|
||||||
|
|
||||||
return summary.lastStage
|
return summary.lastStage
|
||||||
? `Bootstrap transport reached ${summary.lastStage}; waiting for bootstrap confirmation.`
|
? `Bootstrap prompt has not been submitted yet. Last transport stage: ${summary.lastStage}.`
|
||||||
: 'Bootstrap transport is waiting for bootstrap confirmation.';
|
: 'Bootstrap prompt has not been submitted yet.';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProcessBootstrapTimeoutDiagnostic(
|
export function buildProcessBootstrapTimeoutDiagnostic(
|
||||||
summary: ProcessBootstrapTransportSummary
|
summary: ProcessBootstrapTransportSummary
|
||||||
): string {
|
): string {
|
||||||
|
if (summary.submitted) {
|
||||||
|
return summary.lastStage
|
||||||
|
? `Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}`
|
||||||
|
: 'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout.';
|
||||||
|
}
|
||||||
|
|
||||||
return summary.lastStage
|
return summary.lastStage
|
||||||
? `Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}`
|
? `Bootstrap prompt was not submitted before timeout. Last transport stage: ${summary.lastStage}`
|
||||||
: 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
: 'Bootstrap prompt was not submitted before timeout.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ export interface McpLaunchSpec {
|
||||||
args: string[];
|
args: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface McpLaunchSpecResolveProgress {
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpLaunchSpecResolveOptions {
|
||||||
|
onProgress?: (progress: McpLaunchSpecResolveProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const MCP_SERVER_NAME = 'agent-teams';
|
const MCP_SERVER_NAME = 'agent-teams';
|
||||||
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
||||||
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
||||||
|
|
@ -158,15 +167,24 @@ export function clearResolvedNodePathForTests(): void {
|
||||||
_resolvedNodePath = undefined;
|
_resolvedNodePath = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitProgress(
|
||||||
|
options: McpLaunchSpecResolveOptions | undefined,
|
||||||
|
phase: string,
|
||||||
|
message: string
|
||||||
|
): void {
|
||||||
|
options?.onProgress?.({ phase, message });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the real `node` binary path. In Electron, process.execPath is the
|
* Find the real `node` binary path. In Electron, process.execPath is the
|
||||||
* Electron binary — NOT node — so we must resolve node separately.
|
* Electron binary — NOT node — so we must resolve node separately.
|
||||||
* Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency).
|
* Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency).
|
||||||
*/
|
*/
|
||||||
async function resolveNodePath(): Promise<string> {
|
async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<string> {
|
||||||
if (_resolvedNodePath) return _resolvedNodePath;
|
if (_resolvedNodePath) return _resolvedNodePath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
||||||
const resolved = await new Promise<string>((resolve, reject) => {
|
const resolved = await new Promise<string>((resolve, reject) => {
|
||||||
execFile(
|
execFile(
|
||||||
'node',
|
'node',
|
||||||
|
|
@ -180,12 +198,14 @@ async function resolveNodePath(): Promise<string> {
|
||||||
});
|
});
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
_resolvedNodePath = resolved;
|
_resolvedNodePath = resolved;
|
||||||
|
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||||
return _resolvedNodePath;
|
return _resolvedNodePath;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// node not found or timed out — use bare 'node' and let the OS resolve it
|
// node not found or timed out — use bare 'node' and let the OS resolve it
|
||||||
}
|
}
|
||||||
_resolvedNodePath = 'node';
|
_resolvedNodePath = 'node';
|
||||||
|
emitProgress(options, 'node-runtime-fallback', 'Using system Node.js command...');
|
||||||
return _resolvedNodePath;
|
return _resolvedNodePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,10 +219,11 @@ async function resolveNodePath(): Promise<string> {
|
||||||
*
|
*
|
||||||
* Returns the resolved index.js path (stable copy or resourcesPath fallback).
|
* Returns the resolved index.js path (stable copy or resourcesPath fallback).
|
||||||
*/
|
*/
|
||||||
async function resolvePackagedServerEntry(): Promise<string> {
|
async function resolvePackagedServerEntry(options?: McpLaunchSpecResolveOptions): Promise<string> {
|
||||||
const fallbackEntry = getPackagedServerEntry();
|
const fallbackEntry = getPackagedServerEntry();
|
||||||
if (!isPackagedApp()) return fallbackEntry;
|
if (!isPackagedApp()) return fallbackEntry;
|
||||||
|
|
||||||
|
emitProgress(options, 'packaged-server', 'Checking packaged MCP server...');
|
||||||
const appVersion = getAppVersion();
|
const appVersion = getAppVersion();
|
||||||
const baseDir = getMcpServerBasePath();
|
const baseDir = getMcpServerBasePath();
|
||||||
const finalDir = path.join(baseDir, appVersion);
|
const finalDir = path.join(baseDir, appVersion);
|
||||||
|
|
@ -210,6 +231,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
|
||||||
|
|
||||||
// Reuse existing valid copy
|
// Reuse existing valid copy
|
||||||
if (await hasValidServerCopy(finalDir)) {
|
if (await hasValidServerCopy(finalDir)) {
|
||||||
|
emitProgress(options, 'packaged-server-reuse', 'Using cached MCP server copy...');
|
||||||
return finalEntry;
|
return finalEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +252,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
|
||||||
return fallbackEntry;
|
return fallbackEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitProgress(options, 'packaged-server-copy', 'Copying MCP server to app data...');
|
||||||
// Atomic: copy to temp dir, then rename to final
|
// Atomic: copy to temp dir, then rename to final
|
||||||
const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`);
|
const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`);
|
||||||
await fs.promises.mkdir(tmpDir, { recursive: true });
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||||
|
|
@ -254,6 +277,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`);
|
logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`);
|
||||||
|
emitProgress(options, 'packaged-server-ready', 'MCP server copy is ready...');
|
||||||
return finalEntry;
|
return finalEntry;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -265,16 +289,18 @@ async function resolvePackagedServerEntry(): Promise<string> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
export async function resolveAgentTeamsMcpLaunchSpec(
|
||||||
|
options: McpLaunchSpecResolveOptions = {}
|
||||||
|
): Promise<McpLaunchSpec> {
|
||||||
const checked: string[] = [];
|
const checked: string[] = [];
|
||||||
|
|
||||||
// 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath
|
// 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath
|
||||||
if (isPackagedApp()) {
|
if (isPackagedApp()) {
|
||||||
const packagedEntry = await resolvePackagedServerEntry();
|
const packagedEntry = await resolvePackagedServerEntry(options);
|
||||||
checked.push(packagedEntry);
|
checked.push(packagedEntry);
|
||||||
if (await pathExists(packagedEntry)) {
|
if (await pathExists(packagedEntry)) {
|
||||||
return {
|
return {
|
||||||
command: await resolveNodePath(),
|
command: await resolveNodePath(options),
|
||||||
args: [packagedEntry],
|
args: [packagedEntry],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -283,12 +309,14 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||||
|
|
||||||
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
|
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
|
||||||
const sourceEntry = getSourceServerEntry();
|
const sourceEntry = getSourceServerEntry();
|
||||||
|
emitProgress(options, 'source-entry', 'Checking MCP source entry...');
|
||||||
checked.push(sourceEntry);
|
checked.push(sourceEntry);
|
||||||
if (await pathExists(sourceEntry)) {
|
if (await pathExists(sourceEntry)) {
|
||||||
|
emitProgress(options, 'tsx-runner', 'Resolving MCP TypeScript runner...');
|
||||||
const tsxCli = await resolveWorkspaceTsxCli(checked);
|
const tsxCli = await resolveWorkspaceTsxCli(checked);
|
||||||
if (tsxCli) {
|
if (tsxCli) {
|
||||||
return {
|
return {
|
||||||
command: await resolveNodePath(),
|
command: await resolveNodePath(options),
|
||||||
args: [tsxCli, sourceEntry],
|
args: [tsxCli, sourceEntry],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -296,10 +324,11 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||||
|
|
||||||
// 3. Dev mode fallback — use built dist when source execution is unavailable
|
// 3. Dev mode fallback — use built dist when source execution is unavailable
|
||||||
const builtEntry = getBuiltServerEntry();
|
const builtEntry = getBuiltServerEntry();
|
||||||
|
emitProgress(options, 'built-entry', 'Checking built MCP server entry...');
|
||||||
checked.push(builtEntry);
|
checked.push(builtEntry);
|
||||||
if (await pathExists(builtEntry)) {
|
if (await pathExists(builtEntry)) {
|
||||||
return {
|
return {
|
||||||
command: await resolveNodePath(),
|
command: await resolveNodePath(options),
|
||||||
args: [builtEntry],
|
args: [builtEntry],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,9 @@ import {
|
||||||
getTasksBasePath,
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
|
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||||
import { isProcessAlive } from '@main/utils/processHealth';
|
import { isProcessAlive } from '@main/utils/processHealth';
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
||||||
import {
|
import {
|
||||||
|
|
@ -157,15 +157,6 @@ import {
|
||||||
parseBootstrapRuntimeProofDetail,
|
parseBootstrapRuntimeProofDetail,
|
||||||
validateBootstrapRuntimeProofEnvelope,
|
validateBootstrapRuntimeProofEnvelope,
|
||||||
} from './bootstrap/BootstrapProofValidation';
|
} from './bootstrap/BootstrapProofValidation';
|
||||||
import {
|
|
||||||
buildProcessBootstrapPendingDiagnostic,
|
|
||||||
buildProcessBootstrapTimeoutDiagnostic,
|
|
||||||
deriveProcessTransportProjectionPhase,
|
|
||||||
sanitizeProcessRuntimeEventFilePrefix,
|
|
||||||
summarizeProcessBootstrapTransportEvents,
|
|
||||||
type ProcessBootstrapTransportEvent,
|
|
||||||
type ProcessBootstrapTransportSummary,
|
|
||||||
} from './ProcessBootstrapTransportEvidence';
|
|
||||||
import {
|
import {
|
||||||
buildNativeAppManagedBootstrapSpecs,
|
buildNativeAppManagedBootstrapSpecs,
|
||||||
type NativeAppManagedBootstrapSpec,
|
type NativeAppManagedBootstrapSpec,
|
||||||
|
|
@ -247,6 +238,15 @@ import {
|
||||||
} from './idleNotificationMainProcessSemantics';
|
} from './idleNotificationMainProcessSemantics';
|
||||||
import { withInboxLock } from './inboxLock';
|
import { withInboxLock } from './inboxLock';
|
||||||
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||||
|
import {
|
||||||
|
buildProcessBootstrapPendingDiagnostic,
|
||||||
|
buildProcessBootstrapTimeoutDiagnostic,
|
||||||
|
deriveProcessTransportProjectionPhase,
|
||||||
|
type ProcessBootstrapTransportEvent,
|
||||||
|
type ProcessBootstrapTransportSummary,
|
||||||
|
sanitizeProcessRuntimeEventFilePrefix,
|
||||||
|
summarizeProcessBootstrapTransportEvents,
|
||||||
|
} from './ProcessBootstrapTransportEvidence';
|
||||||
import {
|
import {
|
||||||
boundLaunchDiagnostics,
|
boundLaunchDiagnostics,
|
||||||
buildProgressLiveOutput,
|
buildProgressLiveOutput,
|
||||||
|
|
@ -289,6 +289,7 @@ import {
|
||||||
sanitizeProcessCommandForDiagnostics,
|
sanitizeProcessCommandForDiagnostics,
|
||||||
} from './TeamRuntimeLivenessResolver';
|
} from './TeamRuntimeLivenessResolver';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
|
import { TeamTaskActivityIntervalService } from './TeamTaskActivityIntervalService';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
|
||||||
|
|
@ -5451,6 +5452,8 @@ export class TeamProvisioningService {
|
||||||
| null = null;
|
| null = null;
|
||||||
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
||||||
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
|
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
|
||||||
|
private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService();
|
||||||
|
private readonly crashRepairedActivityIntervalsByTeam = new Set<string>();
|
||||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||||
private helpOutputCache: string | null = null;
|
private helpOutputCache: string | null = null;
|
||||||
private helpOutputCacheTime = 0;
|
private helpOutputCacheTime = 0;
|
||||||
|
|
@ -5504,6 +5507,15 @@ export class TeamProvisioningService {
|
||||||
this.scheduleStaleAnthropicTeamApiKeyHelperCleanup();
|
this.scheduleStaleAnthropicTeamApiKeyHelperCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private repairStaleTaskActivityIntervalsOnce(
|
||||||
|
teamName: string,
|
||||||
|
launchSnapshot?: PersistedTeamLaunchSnapshot | null
|
||||||
|
): void {
|
||||||
|
if (this.crashRepairedActivityIntervalsByTeam.has(teamName)) return;
|
||||||
|
this.taskActivityIntervalService.repairStaleIntervalsAfterCrash(teamName, launchSnapshot);
|
||||||
|
this.crashRepairedActivityIntervalsByTeam.add(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleStaleAnthropicTeamApiKeyHelperCleanup(): void {
|
private scheduleStaleAnthropicTeamApiKeyHelperCleanup(): void {
|
||||||
void cleanupStaleAnthropicTeamApiKeyHelpers({
|
void cleanupStaleAnthropicTeamApiKeyHelpers({
|
||||||
baseClaudeDir: getClaudeBasePath(),
|
baseClaudeDir: getClaudeBasePath(),
|
||||||
|
|
@ -12255,6 +12267,20 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prev.runtimeAlive === true && next.runtimeAlive !== true) {
|
||||||
|
this.taskActivityIntervalService.pauseActiveIntervalsForMember(
|
||||||
|
run.teamName,
|
||||||
|
memberName,
|
||||||
|
updatedAt
|
||||||
|
);
|
||||||
|
} else if (prev.runtimeAlive !== true && next.runtimeAlive === true) {
|
||||||
|
this.taskActivityIntervalService.resumeActiveIntervalsForMember(
|
||||||
|
run.teamName,
|
||||||
|
memberName,
|
||||||
|
updatedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
run.memberSpawnStatuses.set(memberName, next);
|
run.memberSpawnStatuses.set(memberName, next);
|
||||||
if (
|
if (
|
||||||
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
|
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
|
||||||
|
|
@ -12394,10 +12420,21 @@ export class TeamProvisioningService {
|
||||||
}> {
|
}> {
|
||||||
const readPersistedStatuses = async (resolvedRunId: string | null) => {
|
const readPersistedStatuses = async (resolvedRunId: string | null) => {
|
||||||
const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName);
|
const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName);
|
||||||
|
this.repairStaleTaskActivityIntervalsOnce(teamName, snapshot);
|
||||||
const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, {
|
const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, {
|
||||||
openCodeSecondaryBootstrapPendingMembers:
|
openCodeSecondaryBootstrapPendingMembers:
|
||||||
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
|
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
|
||||||
});
|
});
|
||||||
|
const runtimeObservedAt = nowIso();
|
||||||
|
for (const [memberName, entry] of Object.entries(nextStatuses)) {
|
||||||
|
if (entry.runtimeAlive === true) {
|
||||||
|
this.taskActivityIntervalService.resumeActiveIntervalsForMember(
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
runtimeObservedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
||||||
const summary = expectedMembers
|
const summary = expectedMembers
|
||||||
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
|
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
|
||||||
|
|
@ -16886,6 +16923,10 @@ export class TeamProvisioningService {
|
||||||
if (existingProvisioningRunId) {
|
if (existingProvisioningRunId) {
|
||||||
return { runId: existingProvisioningRunId };
|
return { runId: existingProvisioningRunId };
|
||||||
}
|
}
|
||||||
|
const previousLaunchSnapshot = await this.launchStateStore
|
||||||
|
.read(request.teamName)
|
||||||
|
.catch(() => null);
|
||||||
|
this.repairStaleTaskActivityIntervalsOnce(request.teamName, previousLaunchSnapshot);
|
||||||
const stopAllGenerationAtStart = this.stopAllTeamsGeneration;
|
const stopAllGenerationAtStart = this.stopAllTeamsGeneration;
|
||||||
assertAppDeterministicBootstrapEnabled();
|
assertAppDeterministicBootstrapEnabled();
|
||||||
if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) {
|
if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) {
|
||||||
|
|
@ -25806,6 +25847,7 @@ export class TeamProvisioningService {
|
||||||
*/
|
*/
|
||||||
async stopTeam(teamName: string): Promise<void> {
|
async stopTeam(teamName: string): Promise<void> {
|
||||||
this.invalidateRuntimeSnapshotCaches(teamName);
|
this.invalidateRuntimeSnapshotCaches(teamName);
|
||||||
|
this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName);
|
||||||
this.stopPersistentTeamMembers(teamName);
|
this.stopPersistentTeamMembers(teamName);
|
||||||
|
|
||||||
const runId = this.getTrackedRunId(teamName);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
|
|
@ -26301,6 +26343,7 @@ export class TeamProvisioningService {
|
||||||
if (orphanOnly.length > 0) {
|
if (orphanOnly.length > 0) {
|
||||||
logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`);
|
logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`);
|
||||||
for (const teamName of orphanOnly) {
|
for (const teamName of orphanOnly) {
|
||||||
|
this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName);
|
||||||
this.stopPersistentTeamMembers(teamName);
|
this.stopPersistentTeamMembers(teamName);
|
||||||
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||||
}
|
}
|
||||||
|
|
@ -30828,7 +30871,14 @@ export class TeamProvisioningService {
|
||||||
return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode';
|
return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode';
|
||||||
});
|
});
|
||||||
if (configHasOpenCodeMember) {
|
if (configHasOpenCodeMember) {
|
||||||
return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId);
|
return this.buildConfigLaunchCompatibilityReport(
|
||||||
|
teamName,
|
||||||
|
configMembers,
|
||||||
|
leadProviderId,
|
||||||
|
{
|
||||||
|
ignoredInboxNames: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const configMembersByName = new Map(
|
const configMembersByName = new Map(
|
||||||
configMembers.map((member) => [member.name.toLowerCase(), member] as const)
|
configMembers.map((member) => [member.name.toLowerCase(), member] as const)
|
||||||
|
|
@ -30915,7 +30965,8 @@ export class TeamProvisioningService {
|
||||||
private buildConfigLaunchCompatibilityReport(
|
private buildConfigLaunchCompatibilityReport(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
configMembers: TeamCreateRequest['members'],
|
configMembers: TeamCreateRequest['members'],
|
||||||
leadProviderId?: TeamProviderId
|
leadProviderId?: TeamProviderId,
|
||||||
|
options: { ignoredInboxNames?: boolean } = {}
|
||||||
): TeamLaunchCompatibilityReport {
|
): TeamLaunchCompatibilityReport {
|
||||||
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
|
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -30957,8 +31008,10 @@ export class TeamProvisioningService {
|
||||||
rosterSource: 'config',
|
rosterSource: 'config',
|
||||||
members: configMembers,
|
members: configMembers,
|
||||||
warnings: [
|
warnings: [
|
||||||
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
|
options.ignoredInboxNames
|
||||||
'Run a fresh team bootstrap to persist stable member metadata.',
|
? 'members.meta.json is missing; launch used complete config.json member metadata instead of inbox fallback to preserve mixed provider/model layout.'
|
||||||
|
: 'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
|
||||||
|
'Run a fresh team bootstrap to persist stable member metadata.',
|
||||||
],
|
],
|
||||||
blockers: [],
|
blockers: [],
|
||||||
repairAction: 'materialize-members-meta',
|
repairAction: 'materialize-members-meta',
|
||||||
|
|
|
||||||
269
src/main/services/team/TeamTaskActivityIntervalService.ts
Normal file
269
src/main/services/team/TeamTaskActivityIntervalService.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PersistedTeamLaunchMemberState,
|
||||||
|
PersistedTeamLaunchSnapshot,
|
||||||
|
TaskReviewInterval,
|
||||||
|
TeamTask,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
interface ActivityIntervalResult {
|
||||||
|
changedTasks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutableTeamTask = TeamTask & {
|
||||||
|
reviewIntervals?: TaskReviewInterval[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CRASH_REPAIR_GRACE_MS = 5_000;
|
||||||
|
|
||||||
|
function normalizeMemberName(value: string | null | undefined): string {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoMs(value: string | null | undefined): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(ms: number): string {
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCloseIso(startedAt: string, at: string): string {
|
||||||
|
const startedAtMs = parseIsoMs(startedAt);
|
||||||
|
const atMs = parseIsoMs(at);
|
||||||
|
if (startedAtMs <= 0) return at;
|
||||||
|
if (atMs <= startedAtMs) return toIso(startedAtMs);
|
||||||
|
return toIso(atMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemberState): string {
|
||||||
|
const startedAtMs = parseIsoMs(startedAt);
|
||||||
|
const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now();
|
||||||
|
const evidenceMs = Math.max(
|
||||||
|
parseIsoMs(member?.lastHeartbeatAt),
|
||||||
|
parseIsoMs(member?.runtimeLastSeenAt),
|
||||||
|
parseIsoMs(member?.lastRuntimeAliveAt)
|
||||||
|
);
|
||||||
|
const closeMs =
|
||||||
|
evidenceMs > 0
|
||||||
|
? Math.max(safeStartedAtMs, evidenceMs + CRASH_REPAIR_GRACE_MS)
|
||||||
|
: safeStartedAtMs + CRASH_REPAIR_GRACE_MS;
|
||||||
|
const boundedCloseMs = Math.max(safeStartedAtMs, Math.min(Date.now(), closeMs));
|
||||||
|
return toIso(boundedCloseMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOpenWorkInterval(task: MutableTeamTask): boolean {
|
||||||
|
return (
|
||||||
|
Array.isArray(task.workIntervals) &&
|
||||||
|
task.workIntervals.some((interval) => !interval.completedAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOpenReviewInterval(task: MutableTeamTask, reviewer: string): boolean {
|
||||||
|
const reviewerKey = normalizeMemberName(reviewer);
|
||||||
|
return (
|
||||||
|
Array.isArray(task.reviewIntervals) &&
|
||||||
|
task.reviewIntervals.some(
|
||||||
|
(interval) => !interval.completedAt && normalizeMemberName(interval.reviewer) === reviewerKey
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOpenWorkIntervals(task: MutableTeamTask, at: string, owner?: string): boolean {
|
||||||
|
if (!Array.isArray(task.workIntervals)) return false;
|
||||||
|
if (owner && normalizeMemberName(task.owner) !== normalizeMemberName(owner)) return false;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
task.workIntervals = task.workIntervals.map((interval) => {
|
||||||
|
if (interval.completedAt) return interval;
|
||||||
|
changed = true;
|
||||||
|
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOpenReviewIntervals(task: MutableTeamTask, at: string, reviewer?: string): boolean {
|
||||||
|
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||||
|
const reviewerKey = normalizeMemberName(reviewer);
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||||
|
if (interval.completedAt) return interval;
|
||||||
|
if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval;
|
||||||
|
changed = true;
|
||||||
|
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveReviewActor(task: MutableTeamTask): string | null {
|
||||||
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||||
|
const event = events[index];
|
||||||
|
if (event.type === 'review_started') {
|
||||||
|
return typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.type === 'review_approved' ||
|
||||||
|
event.type === 'review_changes_requested' ||
|
||||||
|
(event.type === 'status_changed' &&
|
||||||
|
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')) ||
|
||||||
|
event.type === 'task_created'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTaskFile(filePath: string): MutableTeamTask | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTaskFile(filePath: string, task: MutableTeamTask): void {
|
||||||
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(task, null, 2));
|
||||||
|
fs.renameSync(tempPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeamTaskActivityIntervalService {
|
||||||
|
private mutateTeamTasks(
|
||||||
|
teamName: string,
|
||||||
|
mutate: (task: MutableTeamTask) => boolean
|
||||||
|
): ActivityIntervalResult {
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(tasksDir);
|
||||||
|
} catch {
|
||||||
|
return { changedTasks: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let changedTasks = 0;
|
||||||
|
for (const fileName of entries) {
|
||||||
|
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||||
|
const filePath = path.join(tasksDir, fileName);
|
||||||
|
const task = readTaskFile(filePath);
|
||||||
|
if (!task) continue;
|
||||||
|
if (!mutate(task)) continue;
|
||||||
|
writeTaskFile(filePath, task);
|
||||||
|
changedTasks += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedTasks > 0) {
|
||||||
|
TeamTaskReader.invalidateAllTasksCache();
|
||||||
|
}
|
||||||
|
return { changedTasks };
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseActiveIntervalsForTeam(
|
||||||
|
teamName: string,
|
||||||
|
at = new Date().toISOString()
|
||||||
|
): ActivityIntervalResult {
|
||||||
|
return this.mutateTeamTasks(teamName, (task) => {
|
||||||
|
const changedWork = closeOpenWorkIntervals(task, at);
|
||||||
|
const changedReview = closeOpenReviewIntervals(task, at);
|
||||||
|
return changedWork || changedReview;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseActiveIntervalsForMember(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
at = new Date().toISOString()
|
||||||
|
): ActivityIntervalResult {
|
||||||
|
return this.mutateTeamTasks(teamName, (task) => {
|
||||||
|
const changedWork = closeOpenWorkIntervals(task, at, memberName);
|
||||||
|
const changedReview = closeOpenReviewIntervals(task, at, memberName);
|
||||||
|
return changedWork || changedReview;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeActiveIntervalsForMember(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
at = new Date().toISOString()
|
||||||
|
): ActivityIntervalResult {
|
||||||
|
const memberKey = normalizeMemberName(memberName);
|
||||||
|
if (!memberKey) return { changedTasks: 0 };
|
||||||
|
|
||||||
|
return this.mutateTeamTasks(teamName, (task) => {
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
task.status === 'in_progress' &&
|
||||||
|
normalizeMemberName(task.owner) === memberKey &&
|
||||||
|
!hasOpenWorkInterval(task)
|
||||||
|
) {
|
||||||
|
task.workIntervals = [
|
||||||
|
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
|
||||||
|
{ startedAt: at },
|
||||||
|
];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeReviewer = getActiveReviewActor(task);
|
||||||
|
if (
|
||||||
|
activeReviewer &&
|
||||||
|
normalizeMemberName(activeReviewer) === memberKey &&
|
||||||
|
!hasOpenReviewInterval(task, activeReviewer)
|
||||||
|
) {
|
||||||
|
task.reviewIntervals = [
|
||||||
|
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
|
||||||
|
{ reviewer: activeReviewer, startedAt: at },
|
||||||
|
];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
repairStaleIntervalsAfterCrash(
|
||||||
|
teamName: string,
|
||||||
|
launchSnapshot?: PersistedTeamLaunchSnapshot | null
|
||||||
|
): ActivityIntervalResult {
|
||||||
|
const memberByName = new Map<string, PersistedTeamLaunchMemberState>();
|
||||||
|
for (const member of Object.values(launchSnapshot?.members ?? {})) {
|
||||||
|
memberByName.set(normalizeMemberName(member.name), member);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mutateTeamTasks(teamName, (task) => {
|
||||||
|
let changed = false;
|
||||||
|
if (Array.isArray(task.workIntervals)) {
|
||||||
|
const ownerMember = memberByName.get(normalizeMemberName(task.owner));
|
||||||
|
task.workIntervals = task.workIntervals.map((interval) => {
|
||||||
|
if (interval.completedAt) return interval;
|
||||||
|
changed = true;
|
||||||
|
return { ...interval, completedAt: crashRepairCloseIso(interval.startedAt, ownerMember) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(task.reviewIntervals)) {
|
||||||
|
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||||
|
if (interval.completedAt) return interval;
|
||||||
|
const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer));
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...interval,
|
||||||
|
completedAt: crashRepairCloseIso(interval.startedAt, reviewerMember),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskHistoryEvent,
|
TaskHistoryEvent,
|
||||||
TaskRef,
|
TaskRef,
|
||||||
|
TaskReviewInterval,
|
||||||
TaskWorkInterval,
|
TaskWorkInterval,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -194,6 +195,25 @@ export class TeamTaskReader {
|
||||||
completedAt: i.completedAt,
|
completedAt: i.completedAt,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const reviewIntervals: TaskReviewInterval[] | undefined = Array.isArray(
|
||||||
|
parsed.reviewIntervals
|
||||||
|
)
|
||||||
|
? (parsed.reviewIntervals as unknown[])
|
||||||
|
.filter(
|
||||||
|
(i): i is { reviewer: string; startedAt: string; completedAt?: string } =>
|
||||||
|
Boolean(i) &&
|
||||||
|
typeof i === 'object' &&
|
||||||
|
typeof (i as Record<string, unknown>).reviewer === 'string' &&
|
||||||
|
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||||||
|
((i as Record<string, unknown>).completedAt === undefined ||
|
||||||
|
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||||
|
)
|
||||||
|
.map((i) => ({
|
||||||
|
reviewer: i.reviewer,
|
||||||
|
startedAt: i.startedAt,
|
||||||
|
completedAt: i.completedAt,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||||
parsed.status as TeamTask['status']
|
parsed.status as TeamTask['status']
|
||||||
)
|
)
|
||||||
|
|
@ -223,6 +243,7 @@ export class TeamTaskReader {
|
||||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||||
status,
|
status,
|
||||||
workIntervals,
|
workIntervals,
|
||||||
|
reviewIntervals,
|
||||||
historyEvents,
|
historyEvents,
|
||||||
blocks: Array.isArray(parsed.blocks)
|
blocks: Array.isArray(parsed.blocks)
|
||||||
? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')
|
? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const LEGACY_USER_DATA_DIR_NAMES = [
|
const LEGACY_USER_DATA_DIR_NAMES = [
|
||||||
'Claude Agent Teams UI',
|
|
||||||
'claude-agent-teams-ui',
|
|
||||||
'agent-teams-ai',
|
'agent-teams-ai',
|
||||||
'Agent Teams UI',
|
'Agent Teams UI',
|
||||||
|
'Claude Agent Teams UI',
|
||||||
|
'claude-agent-teams-ui',
|
||||||
'claude-devtools',
|
'claude-devtools',
|
||||||
'claude-code-context',
|
'claude-code-context',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
@ -72,6 +72,9 @@ const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([
|
||||||
'Trust Tokens-journal',
|
'Trust Tokens-journal',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const DURABLE_USER_DATA_ROOT_NAMES = new Set(['data', 'backups']);
|
||||||
|
const PREFERRED_USER_DATA_DIR_NAME = 'agent-teams-ai';
|
||||||
|
|
||||||
const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000;
|
const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
export function getLegacyElectronUserDataCandidates(currentPath: string): string[] {
|
export function getLegacyElectronUserDataCandidates(currentPath: string): string[] {
|
||||||
|
|
@ -104,6 +107,30 @@ export function migrateElectronUserDataDirectory(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preferredExistingPath = selectPreferredElectronUserDataPath(currentPath);
|
||||||
|
if (preferredExistingPath) {
|
||||||
|
try {
|
||||||
|
setLegacyElectronPaths(app, preferredExistingPath, logger);
|
||||||
|
logger?.info(`Reusing preferred Electron userData at ${preferredExistingPath}`);
|
||||||
|
return {
|
||||||
|
currentPath,
|
||||||
|
legacyPath: preferredExistingPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger?.warn(`Electron userData preferred reuse failed: ${stringifyError(error)}`);
|
||||||
|
return {
|
||||||
|
currentPath,
|
||||||
|
legacyPath: preferredExistingPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
|
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
|
||||||
return {
|
return {
|
||||||
currentPath,
|
currentPath,
|
||||||
|
|
@ -209,15 +236,23 @@ export function migrateElectronUserDataDirectory(
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectLegacyElectronUserDataPath(currentPath: string): string | null {
|
function selectLegacyElectronUserDataPath(currentPath: string): string | null {
|
||||||
const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists);
|
|
||||||
return (
|
return (
|
||||||
candidates.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ??
|
getLegacyElectronUserDataCandidates(currentPath)
|
||||||
candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ??
|
.filter(directoryExists)
|
||||||
candidates[0] ??
|
.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? null
|
||||||
null
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectPreferredElectronUserDataPath(currentPath: string): string | null {
|
||||||
|
const preferredPath = path.join(path.dirname(currentPath), PREFERRED_USER_DATA_DIR_NAME);
|
||||||
|
if (path.resolve(preferredPath) === path.resolve(currentPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return directoryExists(preferredPath) && directoryHasDurableUserDataEntries(preferredPath)
|
||||||
|
? preferredPath
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function setLegacyElectronPaths(
|
function setLegacyElectronPaths(
|
||||||
app: ElectronUserDataMigrationApp,
|
app: ElectronUserDataMigrationApp,
|
||||||
legacyPath: string,
|
legacyPath: string,
|
||||||
|
|
@ -252,7 +287,7 @@ function copyLegacyUserDataDirectory(
|
||||||
|
|
||||||
copyDirectory(legacyPath, tempPath);
|
copyDirectory(legacyPath, tempPath);
|
||||||
|
|
||||||
if (directoryExists(currentPath) && !directoryHasEntries(currentPath)) {
|
if (directoryExists(currentPath) && directoryIsEmpty(currentPath)) {
|
||||||
fs.rmdirSync(currentPath);
|
fs.rmdirSync(currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,9 +395,9 @@ function directoryExists(targetPath: string): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function directoryHasEntries(targetPath: string): boolean {
|
function directoryIsEmpty(targetPath: string): boolean {
|
||||||
try {
|
try {
|
||||||
return fs.readdirSync(targetPath).length > 0;
|
return fs.readdirSync(targetPath).length === 0;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -381,6 +416,12 @@ function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath:
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = path.join(targetPath, entry.name);
|
const entryPath = path.join(targetPath, entry.name);
|
||||||
|
const relativePath = path.relative(rootPath, entryPath);
|
||||||
|
const rootSegment = relativePath.split(path.sep).find(Boolean);
|
||||||
|
if (!rootSegment || !DURABLE_USER_DATA_ROOT_NAMES.has(rootSegment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) {
|
if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,23 @@ const SHELL_ENV_TIMEOUT_MS = 12_000;
|
||||||
let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null;
|
let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null;
|
||||||
let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
|
let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
|
||||||
|
|
||||||
|
export interface ShellEnvResolveProgress {
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellEnvResolveOptions {
|
||||||
|
onProgress?: (progress: ShellEnvResolveProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitProgress(
|
||||||
|
options: ShellEnvResolveOptions | undefined,
|
||||||
|
phase: string,
|
||||||
|
message: string
|
||||||
|
): void {
|
||||||
|
options?.onProgress?.({ phase, message });
|
||||||
|
}
|
||||||
|
|
||||||
function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv {
|
function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv {
|
||||||
const parsed: NodeJS.ProcessEnv = {};
|
const parsed: NodeJS.ProcessEnv = {};
|
||||||
const lines = content.split('\0');
|
const lines = content.split('\0');
|
||||||
|
|
@ -95,14 +112,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
|
||||||
* Tries login shell first (`-lic`), falls back to interactive (`-ic`).
|
* Tries login shell first (`-lic`), falls back to interactive (`-ic`).
|
||||||
* On Windows returns empty object. Result is cached after first success.
|
* On Windows returns empty object. Result is cached after first success.
|
||||||
*/
|
*/
|
||||||
export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
|
export async function resolveInteractiveShellEnv(
|
||||||
|
options: ShellEnvResolveOptions = {}
|
||||||
|
): Promise<NodeJS.ProcessEnv> {
|
||||||
if (cachedInteractiveShellEnv) {
|
if (cachedInteractiveShellEnv) {
|
||||||
|
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
|
||||||
return cachedInteractiveShellEnv;
|
return cachedInteractiveShellEnv;
|
||||||
}
|
}
|
||||||
if (shellEnvResolvePromise) {
|
if (shellEnvResolvePromise) {
|
||||||
|
emitProgress(options, 'shell-env-waiting', 'Waiting for shell environment...');
|
||||||
return shellEnvResolvePromise;
|
return shellEnvResolvePromise;
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
|
emitProgress(options, 'shell-env-skipped', 'Skipping shell environment on Windows...');
|
||||||
cachedInteractiveShellEnv = {};
|
cachedInteractiveShellEnv = {};
|
||||||
return cachedInteractiveShellEnv;
|
return cachedInteractiveShellEnv;
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +132,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
|
||||||
shellEnvResolvePromise = (async () => {
|
shellEnvResolvePromise = (async () => {
|
||||||
const shellPath = process.env.SHELL || '/bin/zsh';
|
const shellPath = process.env.SHELL || '/bin/zsh';
|
||||||
try {
|
try {
|
||||||
|
emitProgress(options, 'shell-env-login', 'Reading login shell environment...');
|
||||||
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
|
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
|
||||||
cachedInteractiveShellEnv = loginEnv;
|
cachedInteractiveShellEnv = loginEnv;
|
||||||
return loginEnv;
|
return loginEnv;
|
||||||
|
|
@ -117,6 +140,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
|
||||||
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
|
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
|
||||||
logger.warn(`Failed to resolve login shell env: ${loginMessage}`);
|
logger.warn(`Failed to resolve login shell env: ${loginMessage}`);
|
||||||
try {
|
try {
|
||||||
|
emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...');
|
||||||
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
|
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
|
||||||
cachedInteractiveShellEnv = interactiveEnv;
|
cachedInteractiveShellEnv = interactiveEnv;
|
||||||
return interactiveEnv;
|
return interactiveEnv;
|
||||||
|
|
@ -124,6 +148,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
|
||||||
const interactiveMessage =
|
const interactiveMessage =
|
||||||
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
|
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
|
||||||
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
|
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
|
||||||
|
emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,7 @@ interface ParsedTask {
|
||||||
reviewState?: unknown;
|
reviewState?: unknown;
|
||||||
metadata?: { _internal?: unknown };
|
metadata?: { _internal?: unknown };
|
||||||
workIntervals?: unknown;
|
workIntervals?: unknown;
|
||||||
|
reviewIntervals?: unknown;
|
||||||
historyEvents?: unknown;
|
historyEvents?: unknown;
|
||||||
attachments?: unknown;
|
attachments?: unknown;
|
||||||
sourceMessageId?: unknown;
|
sourceMessageId?: unknown;
|
||||||
|
|
@ -231,6 +232,12 @@ interface RawWorkInterval {
|
||||||
completedAt?: unknown;
|
completedAt?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawReviewInterval {
|
||||||
|
reviewer?: unknown;
|
||||||
|
startedAt?: unknown;
|
||||||
|
completedAt?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawHistoryEvent {
|
interface RawHistoryEvent {
|
||||||
id?: unknown;
|
id?: unknown;
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
|
|
@ -1215,6 +1222,27 @@ function normalizeWorkIntervals(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReviewIntervals(
|
||||||
|
parsed: ParsedTask
|
||||||
|
): { reviewer: string; startedAt: string; completedAt?: string }[] | undefined {
|
||||||
|
if (!Array.isArray(parsed.reviewIntervals)) return undefined;
|
||||||
|
return (parsed.reviewIntervals as unknown[])
|
||||||
|
.filter(
|
||||||
|
(i): i is RawReviewInterval =>
|
||||||
|
Boolean(i) &&
|
||||||
|
typeof i === 'object' &&
|
||||||
|
typeof (i as RawReviewInterval).reviewer === 'string' &&
|
||||||
|
typeof (i as RawReviewInterval).startedAt === 'string' &&
|
||||||
|
((i as RawReviewInterval).completedAt === undefined ||
|
||||||
|
typeof (i as RawReviewInterval).completedAt === 'string')
|
||||||
|
)
|
||||||
|
.map((i) => ({
|
||||||
|
reviewer: i.reviewer as string,
|
||||||
|
startedAt: i.startedAt as string,
|
||||||
|
completedAt: i.completedAt as string | undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefined {
|
function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefined {
|
||||||
if (!Array.isArray(parsed.historyEvents)) return undefined;
|
if (!Array.isArray(parsed.historyEvents)) return undefined;
|
||||||
return (parsed.historyEvents as unknown[])
|
return (parsed.historyEvents as unknown[])
|
||||||
|
|
@ -1479,6 +1507,7 @@ async function readTasksDirForTeam(
|
||||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||||
status,
|
status,
|
||||||
workIntervals: normalizeWorkIntervals(parsed),
|
workIntervals: normalizeWorkIntervals(parsed),
|
||||||
|
reviewIntervals: normalizeReviewIntervals(parsed),
|
||||||
historyEvents: normalizeHistoryEvents(parsed),
|
historyEvents: normalizeHistoryEvents(parsed),
|
||||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
||||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,
|
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ export const RENDERER_BOOT = 'renderer:boot';
|
||||||
/** Renderer -> main heartbeat (detect renderer stalls) */
|
/** Renderer -> main heartbeat (detect renderer stalls) */
|
||||||
export const RENDERER_HEARTBEAT = 'renderer:heartbeat';
|
export const RENDERER_HEARTBEAT = 'renderer:heartbeat';
|
||||||
|
|
||||||
|
/** Renderer -> main startup status request */
|
||||||
|
export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
|
||||||
|
|
||||||
|
/** Main -> renderer startup progress update */
|
||||||
|
export const APP_STARTUP_PROGRESS = 'appStartup:progress';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Config API Channels
|
// Config API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
API_KEYS_SAVE,
|
API_KEYS_SAVE,
|
||||||
API_KEYS_STORAGE_STATUS,
|
API_KEYS_STORAGE_STATUS,
|
||||||
APP_RELAUNCH,
|
APP_RELAUNCH,
|
||||||
|
APP_STARTUP_GET_STATUS,
|
||||||
|
APP_STARTUP_PROGRESS,
|
||||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||||
CLI_INSTALLER_GET_STATUS,
|
CLI_INSTALLER_GET_STATUS,
|
||||||
CLI_INSTALLER_INSTALL,
|
CLI_INSTALLER_INSTALL,
|
||||||
|
|
@ -249,6 +251,7 @@ import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
ApplyReviewRequest,
|
ApplyReviewRequest,
|
||||||
ApplyReviewResult,
|
ApplyReviewResult,
|
||||||
|
AppStartupStatus,
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
BoardTaskActivityDetailResult,
|
BoardTaskActivityDetailResult,
|
||||||
BoardTaskActivityEntry,
|
BoardTaskActivityEntry,
|
||||||
|
|
@ -480,6 +483,18 @@ const electronAPI: ElectronAPI = {
|
||||||
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
||||||
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
||||||
memberLogStream: createMemberLogStreamBridge(),
|
memberLogStream: createMemberLogStreamBridge(),
|
||||||
|
startup: {
|
||||||
|
getStatus: () => ipcRenderer.invoke(APP_STARTUP_GET_STATUS) as Promise<AppStartupStatus>,
|
||||||
|
onProgress: (callback: (status: AppStartupStatus) => void): (() => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, status: AppStartupStatus): void => {
|
||||||
|
callback(status);
|
||||||
|
};
|
||||||
|
ipcRenderer.on(APP_STARTUP_PROGRESS, listener);
|
||||||
|
return (): void => {
|
||||||
|
ipcRenderer.removeListener(APP_STARTUP_PROGRESS, listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||||
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
||||||
|
|
|
||||||
|
|
@ -895,7 +895,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
||||||
key={team.teamName}
|
key={team.teamName}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-l-[3px] border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
|
className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
|
||||||
style={teamColorSet ? { borderLeftColor: teamColorSet.border } : undefined}
|
style={teamColorSet ? { borderLeftColor: teamColorSet.border } : undefined}
|
||||||
onClick={() => openTeamTab(team.teamName, team.projectPath)}
|
onClick={() => openTeamTab(team.teamName, team.projectPath)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -1380,6 +1380,18 @@ export const CreateTeamDialog = ({
|
||||||
[request, launchTeam]
|
[request, launchTeam]
|
||||||
);
|
);
|
||||||
const modelValidationError = useMemo(() => {
|
const modelValidationError = useMemo(() => {
|
||||||
|
if (selectedProviderId === 'opencode') {
|
||||||
|
if (!selectedModel.trim()) {
|
||||||
|
return 'OpenCode lead requires a selected model.';
|
||||||
|
}
|
||||||
|
const activeMemberCount = soloTeam
|
||||||
|
? 0
|
||||||
|
: effectiveMemberDrafts.filter((member) => !member.removedAt && member.name.trim()).length;
|
||||||
|
if (activeMemberCount === 0) {
|
||||||
|
return 'OpenCode lead requires at least one OpenCode teammate.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const leadError = getTeamModelSelectionError(
|
const leadError = getTeamModelSelectionError(
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
|
|
@ -1409,7 +1421,13 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [effectiveMemberDrafts, runtimeProviderStatusById, selectedModel, selectedProviderId]);
|
}, [
|
||||||
|
effectiveMemberDrafts,
|
||||||
|
runtimeProviderStatusById,
|
||||||
|
selectedModel,
|
||||||
|
selectedProviderId,
|
||||||
|
soloTeam,
|
||||||
|
]);
|
||||||
const leadModelIssueText = useMemo(() => {
|
const leadModelIssueText = useMemo(() => {
|
||||||
const issue = getProvisioningModelIssue(
|
const issue = getProvisioningModelIssue(
|
||||||
prepareChecks,
|
prepareChecks,
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,8 @@ import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibili
|
||||||
import {
|
import {
|
||||||
computeEffectiveTeamModel,
|
computeEffectiveTeamModel,
|
||||||
formatTeamModelSummary,
|
formatTeamModelSummary,
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL,
|
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL,
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
OPENCODE_ONE_SHOT_DISABLED_REASON,
|
||||||
TeamModelSelector,
|
TeamModelSelector,
|
||||||
} from './TeamModelSelector';
|
} from './TeamModelSelector';
|
||||||
import {
|
import {
|
||||||
|
|
@ -249,6 +249,14 @@ function getStoredTeamProvider(): TeamProviderId {
|
||||||
return normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true);
|
return normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOneShotProviderForMode(
|
||||||
|
providerId: TeamProviderId | undefined,
|
||||||
|
multimodelEnabled: boolean
|
||||||
|
): TeamProviderId {
|
||||||
|
const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled);
|
||||||
|
return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId;
|
||||||
|
}
|
||||||
|
|
||||||
function getStoredTeamModel(providerId: TeamProviderId): string {
|
function getStoredTeamModel(providerId: TeamProviderId): string {
|
||||||
const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
|
const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
|
||||||
if (stored === null) {
|
if (stored === null) {
|
||||||
|
|
@ -412,10 +420,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() =>
|
const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() =>
|
||||||
normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
isLaunchMode
|
||||||
|
? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||||
|
: normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||||
);
|
);
|
||||||
const [selectedModel, setSelectedModelRaw] = useState(() =>
|
const [selectedModel, setSelectedModelRaw] = useState(() =>
|
||||||
getStoredTeamModel(normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled))
|
getStoredTeamModel(
|
||||||
|
isLaunchMode
|
||||||
|
? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||||
|
: normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
|
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
|
||||||
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false);
|
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false);
|
||||||
|
|
@ -623,7 +637,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectedProviderId = (value: TeamProviderId): void => {
|
const setSelectedProviderId = (value: TeamProviderId): void => {
|
||||||
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled);
|
const normalizedValue = isLaunchMode
|
||||||
|
? normalizeLeadProviderForMode(value, multimodelEnabled)
|
||||||
|
: normalizeOneShotProviderForMode(value, multimodelEnabled);
|
||||||
setSelectedProviderIdRaw(normalizedValue);
|
setSelectedProviderIdRaw(normalizedValue);
|
||||||
localStorage.setItem('team:lastSelectedProvider', normalizedValue);
|
localStorage.setItem('team:lastSelectedProvider', normalizedValue);
|
||||||
if (normalizedValue !== 'anthropic') {
|
if (normalizedValue !== 'anthropic') {
|
||||||
|
|
@ -736,15 +752,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
promptDraft.setValue(schedule.launchConfig.prompt);
|
promptDraft.setValue(schedule.launchConfig.prompt);
|
||||||
setCustomCwd(schedule.launchConfig.cwd);
|
setCustomCwd(schedule.launchConfig.cwd);
|
||||||
setCwdMode('custom');
|
setCwdMode('custom');
|
||||||
const scheduleProviderId = normalizeLeadProviderForMode(
|
const scheduleProviderId = normalizeOneShotProviderForMode(
|
||||||
schedule.launchConfig.providerId,
|
schedule.launchConfig.providerId,
|
||||||
multimodelEnabled
|
multimodelEnabled
|
||||||
);
|
);
|
||||||
|
const scheduleSourceProviderId = normalizeOptionalTeamProviderId(
|
||||||
|
schedule.launchConfig.providerId
|
||||||
|
);
|
||||||
setSelectedProviderIdRaw(scheduleProviderId);
|
setSelectedProviderIdRaw(scheduleProviderId);
|
||||||
setSelectedModelRaw(
|
setSelectedModelRaw(
|
||||||
schedule.launchConfig.providerId !== 'gemini' &&
|
scheduleSourceProviderId !== 'gemini' &&
|
||||||
|
scheduleSourceProviderId !== 'opencode' &&
|
||||||
scheduleProviderId ===
|
scheduleProviderId ===
|
||||||
normalizeLeadProviderForMode(schedule.launchConfig.providerId, true)
|
normalizeOneShotProviderForMode(schedule.launchConfig.providerId, true)
|
||||||
? (schedule.launchConfig.model ?? '')
|
? (schedule.launchConfig.model ?? '')
|
||||||
: getStoredTeamModel('anthropic')
|
: getStoredTeamModel('anthropic')
|
||||||
);
|
);
|
||||||
|
|
@ -765,7 +785,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
setCwdMode('project');
|
setCwdMode('project');
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
setCustomCwd('');
|
setCustomCwd('');
|
||||||
const storedProviderId = normalizeLeadProviderForMode(
|
const storedProviderId = normalizeOneShotProviderForMode(
|
||||||
getStoredTeamProvider(),
|
getStoredTeamProvider(),
|
||||||
multimodelEnabled
|
multimodelEnabled
|
||||||
);
|
);
|
||||||
|
|
@ -1825,6 +1845,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
cronExpression,
|
cronExpression,
|
||||||
]);
|
]);
|
||||||
const modelValidationError = useMemo(() => {
|
const modelValidationError = useMemo(() => {
|
||||||
|
if (isLaunchMode && selectedProviderId === 'opencode') {
|
||||||
|
if (!selectedModel.trim()) {
|
||||||
|
return 'OpenCode lead requires a selected model.';
|
||||||
|
}
|
||||||
|
const activeMemberCount = effectiveMemberDrafts.filter(
|
||||||
|
(member) => !member.removedAt && member.name.trim()
|
||||||
|
).length;
|
||||||
|
if (activeMemberCount === 0) {
|
||||||
|
return 'OpenCode lead requires at least one OpenCode teammate.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const leadError = getTeamModelSelectionError(
|
const leadError = getTeamModelSelectionError(
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
|
|
@ -2674,10 +2706,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
id="dialog-model"
|
id="dialog-model"
|
||||||
disableGeminiOption={isGeminiUiFrozen()}
|
disableGeminiOption={isGeminiUiFrozen()}
|
||||||
providerDisabledReasonById={{
|
providerDisabledReasonById={{
|
||||||
opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
opencode: OPENCODE_ONE_SHOT_DISABLED_REASON,
|
||||||
}}
|
}}
|
||||||
providerDisabledBadgeLabelById={{
|
providerDisabledBadgeLabelById={{
|
||||||
opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL,
|
opencode: OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<EffortLevelSelector
|
<EffortLevelSelector
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,9 @@ const PROVIDERS: ProviderDef[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
|
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
|
||||||
export const OPENCODE_TEAM_LEAD_DISABLED_REASON =
|
export const OPENCODE_ONE_SHOT_DISABLED_REASON =
|
||||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.';
|
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.';
|
||||||
export const OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL = 'side lane';
|
export const OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL = 'team only';
|
||||||
|
|
||||||
export function getTeamModelLabel(model: string): string {
|
export function getTeamModelLabel(model: string): string {
|
||||||
return getCatalogTeamModelLabel(model) ?? model;
|
return getCatalogTeamModelLabel(model) ?? model;
|
||||||
|
|
@ -118,9 +118,9 @@ export function formatTeamModelSummary(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the effective model string for team provisioning.
|
* Computes the effective model string for team provisioning.
|
||||||
* By default adds [1m] suffix for 1M context (Opus/Sonnet).
|
* By default adds [1m] suffix for Opus 1M context.
|
||||||
* When limitContext=true, returns base model without [1m] (200K context).
|
* When limitContext=true, returns base model without [1m] (200K context).
|
||||||
* Haiku does not support 1M — always returned as-is.
|
* Sonnet and Haiku default to standard context to avoid extra-usage-only variants.
|
||||||
*/
|
*/
|
||||||
export function computeEffectiveTeamModel(
|
export function computeEffectiveTeamModel(
|
||||||
selectedModel: string,
|
selectedModel: string,
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
||||||
details.push(
|
details.push(
|
||||||
names
|
names
|
||||||
? `OpenCode-led mixed team: ${names} use a non-OpenCode provider.`
|
? `OpenCode-led mixed team: ${names} use a non-OpenCode provider.`
|
||||||
: 'OpenCode-led mixed teams are not supported in this phase.'
|
: 'Mixed teams cannot use OpenCode as the lead in this phase.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (hasCodexNative) {
|
if (hasCodexNative) {
|
||||||
|
|
@ -317,7 +317,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
||||||
message: checking
|
message: checking
|
||||||
? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.'
|
? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.'
|
||||||
: hasOpenCodeLeadMixedUnsupported
|
: hasOpenCodeLeadMixedUnsupported
|
||||||
? 'OpenCode teammates can run as secondary runtime lanes under an Anthropic, Codex, or Gemini lead, but OpenCode-led mixed teams are not supported in this phase.'
|
? 'OpenCode can be added as a teammate under an Anthropic, Codex, or Gemini lead, but mixed teams cannot use OpenCode as the lead in this phase.'
|
||||||
: hasExplicitInProcess
|
: hasExplicitInProcess
|
||||||
? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.'
|
? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.'
|
||||||
: 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.',
|
: 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.',
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,6 @@ vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
|
||||||
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
||||||
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
|
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
|
||||||
getTeamProviderLabel: (providerId: string) => providerId,
|
getTeamProviderLabel: (providerId: string) => providerId,
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane',
|
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_REASON:
|
|
||||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
|
||||||
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
|
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitCon
|
||||||
import {
|
import {
|
||||||
getProviderScopedTeamModelLabel,
|
getProviderScopedTeamModelLabel,
|
||||||
getTeamProviderLabel,
|
getTeamProviderLabel,
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL,
|
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
|
||||||
TeamModelSelector,
|
TeamModelSelector,
|
||||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
|
|
@ -151,8 +149,6 @@ export const LeadModelRow = ({
|
||||||
onValueChange={onModelChange}
|
onValueChange={onModelChange}
|
||||||
id="lead-model"
|
id="lead-model"
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
providerDisabledReasonById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON }}
|
|
||||||
providerDisabledBadgeLabelById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL }}
|
|
||||||
modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined}
|
modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined}
|
||||||
/>
|
/>
|
||||||
<EffortLevelSelector
|
<EffortLevelSelector
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
buildMemberLaunchDiagnosticsPayload,
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
hasMemberLaunchDiagnosticsDetails,
|
hasMemberLaunchDiagnosticsDetails,
|
||||||
hasMemberLaunchDiagnosticsError,
|
hasMemberLaunchDiagnosticsError,
|
||||||
|
normalizeMemberLaunchFailureReason,
|
||||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
@ -97,15 +98,6 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLaunchFailureReason(value: string | undefined): string | null {
|
|
||||||
const normalized = value
|
|
||||||
?.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
|
|
||||||
.replace(/^APIError\s*[-:]\s*/i, '');
|
|
||||||
return normalized && normalized.length > 0 ? normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLaunchFailureLinkLabel(url: string): string {
|
function getLaunchFailureLinkLabel(url: string): string {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
|
|
@ -180,6 +172,8 @@ export const MemberCard = memo(function MemberCard({
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||||
|
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
|
||||||
|
spawnUpdatedAt: spawnEntry?.updatedAt,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
|
|
@ -197,6 +191,7 @@ export const MemberCard = memo(function MemberCard({
|
||||||
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
||||||
const displayPresenceLabel =
|
const displayPresenceLabel =
|
||||||
launchVisualState === 'queued' ||
|
launchVisualState === 'queued' ||
|
||||||
|
launchVisualState === 'starting_stale' ||
|
||||||
launchVisualState === 'bootstrap_stalled' ||
|
launchVisualState === 'bootstrap_stalled' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
launchVisualState === 'permission_pending' ||
|
launchVisualState === 'permission_pending' ||
|
||||||
|
|
@ -243,6 +238,7 @@ export const MemberCard = memo(function MemberCard({
|
||||||
(presenceLabel === 'starting' ||
|
(presenceLabel === 'starting' ||
|
||||||
presenceLabel === 'connecting' ||
|
presenceLabel === 'connecting' ||
|
||||||
launchVisualState === 'queued' ||
|
launchVisualState === 'queued' ||
|
||||||
|
launchVisualState === 'starting_stale' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
launchVisualState === 'shell_only' ||
|
launchVisualState === 'shell_only' ||
|
||||||
launchVisualState === 'runtime_candidate' ||
|
launchVisualState === 'runtime_candidate' ||
|
||||||
|
|
@ -289,7 +285,7 @@ export const MemberCard = memo(function MemberCard({
|
||||||
spawnEntry?.runtimeDiagnostic ??
|
spawnEntry?.runtimeDiagnostic ??
|
||||||
spawnEntry?.error;
|
spawnEntry?.error;
|
||||||
const launchFailureReason = showFailedLaunchBadge
|
const launchFailureReason = showFailedLaunchBadge
|
||||||
? normalizeLaunchFailureReason(rawLaunchFailureReason)
|
? normalizeMemberLaunchFailureReason(rawLaunchFailureReason)
|
||||||
: null;
|
: null;
|
||||||
const hasLiveLaunchControls =
|
const hasLiveLaunchControls =
|
||||||
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
||||||
|
|
@ -523,10 +519,17 @@ export const MemberCard = memo(function MemberCard({
|
||||||
className="flex shrink-0 items-center gap-1"
|
className="flex shrink-0 items-center gap-1"
|
||||||
title={runtimeEntry?.runtimeDiagnostic}
|
title={runtimeEntry?.runtimeDiagnostic}
|
||||||
>
|
>
|
||||||
<SyncedLoader2
|
{launchVisualState === 'starting_stale' ? (
|
||||||
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
|
<AlertTriangle
|
||||||
aria-label={launchBadgeLabel}
|
className="size-3.5 shrink-0 text-amber-400"
|
||||||
/>
|
aria-label={launchBadgeLabel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SyncedLoader2
|
||||||
|
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
|
||||||
|
aria-label={launchBadgeLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,8 @@ export const MemberDetailDialog = ({
|
||||||
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||||
spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed}
|
spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed}
|
||||||
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
|
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
|
||||||
|
spawnFirstSpawnAcceptedAt={spawnEntry?.firstSpawnAcceptedAt}
|
||||||
|
spawnUpdatedAt={spawnEntry?.updatedAt}
|
||||||
runtimeEntry={runtimeEntry}
|
runtimeEntry={runtimeEntry}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
onUpdateRole={
|
onUpdateRole={
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ interface MemberDetailHeaderProps {
|
||||||
spawnRuntimeAlive?: boolean;
|
spawnRuntimeAlive?: boolean;
|
||||||
spawnBootstrapConfirmed?: boolean;
|
spawnBootstrapConfirmed?: boolean;
|
||||||
spawnBootstrapStalled?: boolean;
|
spawnBootstrapStalled?: boolean;
|
||||||
|
spawnFirstSpawnAcceptedAt?: string;
|
||||||
|
spawnUpdatedAt?: string;
|
||||||
isLaunchSettling?: boolean;
|
isLaunchSettling?: boolean;
|
||||||
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
||||||
updatingRole?: boolean;
|
updatingRole?: boolean;
|
||||||
|
|
@ -58,6 +60,8 @@ export const MemberDetailHeader = ({
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
spawnBootstrapConfirmed,
|
spawnBootstrapConfirmed,
|
||||||
spawnBootstrapStalled,
|
spawnBootstrapStalled,
|
||||||
|
spawnFirstSpawnAcceptedAt,
|
||||||
|
spawnUpdatedAt,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
updatingRole,
|
updatingRole,
|
||||||
|
|
@ -85,6 +89,8 @@ export const MemberDetailHeader = ({
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
spawnBootstrapConfirmed,
|
spawnBootstrapConfirmed,
|
||||||
spawnBootstrapStalled,
|
spawnBootstrapStalled,
|
||||||
|
spawnFirstSpawnAcceptedAt,
|
||||||
|
spawnUpdatedAt,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
|
|
@ -102,7 +108,8 @@ export const MemberDetailHeader = ({
|
||||||
const badgeLabel =
|
const badgeLabel =
|
||||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||||
? runtimeAdvisoryLabel
|
? runtimeAdvisoryLabel
|
||||||
: launchVisualState === 'bootstrap_stalled' ||
|
: launchVisualState === 'starting_stale' ||
|
||||||
|
launchVisualState === 'bootstrap_stalled' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
launchVisualState === 'permission_pending' ||
|
launchVisualState === 'permission_pending' ||
|
||||||
launchVisualState === 'shell_only' ||
|
launchVisualState === 'shell_only' ||
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||||
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||||
|
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
|
||||||
|
spawnUpdatedAt: spawnEntry?.updatedAt,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
|
|
@ -176,7 +178,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
const badgeLabel =
|
const badgeLabel =
|
||||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||||
? runtimeAdvisoryLabel
|
? runtimeAdvisoryLabel
|
||||||
: launchVisualState === 'bootstrap_stalled' ||
|
: launchVisualState === 'starting_stale' ||
|
||||||
|
launchVisualState === 'bootstrap_stalled' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
launchVisualState === 'permission_pending' ||
|
launchVisualState === 'permission_pending' ||
|
||||||
launchVisualState === 'shell_only' ||
|
launchVisualState === 'shell_only' ||
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,24 @@ function areTaskWorkIntervalsEquivalent(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areTaskReviewIntervalsEquivalent(
|
||||||
|
left: TeamTaskWithKanban['reviewIntervals'],
|
||||||
|
right: TeamTaskWithKanban['reviewIntervals']
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return left === right;
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
return left.every((interval, index) => {
|
||||||
|
const other = right[index];
|
||||||
|
if (!other) return false;
|
||||||
|
return (
|
||||||
|
interval.reviewer === other.reviewer &&
|
||||||
|
interval.startedAt === other.startedAt &&
|
||||||
|
interval.completedAt === other.completedAt
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function areTaskHistoryEventsEquivalent(
|
function areTaskHistoryEventsEquivalent(
|
||||||
left: TeamTaskWithKanban['historyEvents'],
|
left: TeamTaskWithKanban['historyEvents'],
|
||||||
right: TeamTaskWithKanban['historyEvents']
|
right: TeamTaskWithKanban['historyEvents']
|
||||||
|
|
@ -166,6 +184,7 @@ function areMemberTaskMapsEquivalent(
|
||||||
leftTask.reviewState !== rightTask.reviewState ||
|
leftTask.reviewState !== rightTask.reviewState ||
|
||||||
leftTask.kanbanColumn !== rightTask.kanbanColumn ||
|
leftTask.kanbanColumn !== rightTask.kanbanColumn ||
|
||||||
!areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) ||
|
!areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) ||
|
||||||
|
!areTaskReviewIntervalsEquivalent(leftTask.reviewIntervals, rightTask.reviewIntervals) ||
|
||||||
!areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents)
|
!areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,7 @@ export function normalizeLeadProviderForMode(
|
||||||
providerId: TeamProviderId | undefined,
|
providerId: TeamProviderId | undefined,
|
||||||
multimodelEnabled: boolean
|
multimodelEnabled: boolean
|
||||||
): TeamProviderId {
|
): TeamProviderId {
|
||||||
const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled);
|
return normalizeProviderForMode(providerId, multimodelEnabled);
|
||||||
return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeMemberDraftForProviderMode(
|
export function normalizeMemberDraftForProviderMode(
|
||||||
|
|
|
||||||
|
|
@ -237,11 +237,155 @@
|
||||||
animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards;
|
animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards;
|
||||||
will-change: clip-path;
|
will-change: clip-path;
|
||||||
}
|
}
|
||||||
|
#splash-status-row {
|
||||||
|
display: flex;
|
||||||
|
width: min(320px, 78vw);
|
||||||
|
margin-top: 14px;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
#splash-status {
|
||||||
|
min-height: 16px;
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(212, 212, 216, 0.72);
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
#splash-elapsed::before {
|
||||||
|
content: '·';
|
||||||
|
margin-right: 6px;
|
||||||
|
color: rgba(212, 212, 216, 0.34);
|
||||||
|
}
|
||||||
|
#splash-elapsed {
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(212, 212, 216, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#splash-hint {
|
||||||
|
width: min(320px, 78vw);
|
||||||
|
min-height: 15px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(212, 212, 216, 0.54);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
#splash-timeline {
|
||||||
|
display: flex;
|
||||||
|
width: min(320px, 78vw);
|
||||||
|
max-height: 128px;
|
||||||
|
margin-top: 14px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.splash-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
.splash-step.is-current {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.splash-step-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(212, 212, 216, 0.42);
|
||||||
|
}
|
||||||
|
.splash-step.is-current .splash-step-dot {
|
||||||
|
background: #a78bfa;
|
||||||
|
box-shadow: 0 0 12px rgba(167, 139, 250, 0.55);
|
||||||
|
}
|
||||||
|
.splash-step-label,
|
||||||
|
.splash-step-time {
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.splash-step-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgba(212, 212, 216, 0.62);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.splash-step.is-current .splash-step-label {
|
||||||
|
color: rgba(244, 244, 245, 0.86);
|
||||||
|
}
|
||||||
|
.splash-step-time {
|
||||||
|
color: rgba(212, 212, 216, 0.44);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#splash-progress {
|
||||||
|
position: relative;
|
||||||
|
width: min(240px, 64vw);
|
||||||
|
height: 3px;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
#splash-progress-bar {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 38%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #818cf8, #c084fc);
|
||||||
|
animation: splash-progress 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#splash.splash-status-error #splash-status {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
#splash.splash-status-slow #splash-status {
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
#splash.splash-status-error #splash-progress-bar {
|
||||||
|
background: #f87171;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
@keyframes splash-tagline-type {
|
@keyframes splash-tagline-type {
|
||||||
to {
|
to {
|
||||||
clip-path: inset(0 0 0 0);
|
clip-path: inset(0 0 0 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@keyframes splash-progress {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-110%);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: translateX(90%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(260%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Logo node breathing - cycles through 3 agent nodes */
|
/* Logo node breathing - cycles through 3 agent nodes */
|
||||||
@keyframes splash-node {
|
@keyframes splash-node {
|
||||||
|
|
@ -284,6 +428,26 @@
|
||||||
:root.light #splash-tagline {
|
:root.light #splash-tagline {
|
||||||
color: rgba(63, 63, 70, 0.66);
|
color: rgba(63, 63, 70, 0.66);
|
||||||
}
|
}
|
||||||
|
:root.light #splash-status {
|
||||||
|
color: rgba(63, 63, 70, 0.7);
|
||||||
|
}
|
||||||
|
:root.light #splash-elapsed,
|
||||||
|
:root.light .splash-step-time {
|
||||||
|
color: rgba(63, 63, 70, 0.48);
|
||||||
|
}
|
||||||
|
:root.light #splash-hint,
|
||||||
|
:root.light .splash-step-label {
|
||||||
|
color: rgba(63, 63, 70, 0.58);
|
||||||
|
}
|
||||||
|
:root.light .splash-step.is-current .splash-step-label {
|
||||||
|
color: rgba(39, 39, 42, 0.82);
|
||||||
|
}
|
||||||
|
:root.light .splash-step-dot {
|
||||||
|
background: rgba(63, 63, 70, 0.34);
|
||||||
|
}
|
||||||
|
:root.light #splash-progress {
|
||||||
|
background: rgba(79, 70, 229, 0.14);
|
||||||
|
}
|
||||||
:root.light #splash-noise {
|
:root.light #splash-noise {
|
||||||
opacity: 0.02;
|
opacity: 0.02;
|
||||||
}
|
}
|
||||||
|
|
@ -307,6 +471,7 @@
|
||||||
#splash,
|
#splash,
|
||||||
#splash-enhanced-canvas,
|
#splash-enhanced-canvas,
|
||||||
#splash-logo,
|
#splash-logo,
|
||||||
|
#splash-progress-bar,
|
||||||
#splash-tagline > span,
|
#splash-tagline > span,
|
||||||
.splash-node {
|
.splash-node {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
|
|
@ -446,6 +611,13 @@
|
||||||
<div id="splash-copy">
|
<div id="splash-copy">
|
||||||
<div id="splash-text">Agent Teams AI</div>
|
<div id="splash-text">Agent Teams AI</div>
|
||||||
<div id="splash-tagline"><span>Get more done by doing less.</span></div>
|
<div id="splash-tagline"><span>Get more done by doing less.</span></div>
|
||||||
|
<div id="splash-status-row">
|
||||||
|
<div id="splash-status" aria-live="polite">Preparing workspace...</div>
|
||||||
|
<div id="splash-elapsed">0s</div>
|
||||||
|
</div>
|
||||||
|
<div id="splash-hint" aria-live="polite"></div>
|
||||||
|
<div id="splash-progress" aria-hidden="true"><div id="splash-progress-bar"></div></div>
|
||||||
|
<div id="splash-timeline" aria-label="Startup steps"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import { App } from './App';
|
||||||
import { initSentryRenderer } from './sentry';
|
import { initSentryRenderer } from './sentry';
|
||||||
import { initializeNotificationListeners } from './store';
|
import { initializeNotificationListeners } from './store';
|
||||||
|
|
||||||
|
import type { AppStartupStatus, AppStartupStep } from '@shared/types/api';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__claudeTeamsUiDidInit?: boolean;
|
__claudeTeamsUiDidInit?: boolean;
|
||||||
|
|
@ -18,16 +20,199 @@ declare global {
|
||||||
// Sentry must be initialised before React renders.
|
// Sentry must be initialised before React renders.
|
||||||
initSentryRenderer();
|
initSentryRenderer();
|
||||||
|
|
||||||
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev,
|
let root: ReactDOM.Root | null = null;
|
||||||
// which can start duplicate IPC init chains. Make initialization a one-time
|
let latestStartupStatus: AppStartupStatus | null = null;
|
||||||
// module-level side effect guarded by a global flag.
|
let startupTicker: number | undefined;
|
||||||
if (!window.__claudeTeamsUiDidInit) {
|
|
||||||
window.__claudeTeamsUiDidInit = true;
|
const SLOW_STEP_MS = 7_000;
|
||||||
initializeNotificationListeners();
|
const VERY_SLOW_STEP_MS = 14_000;
|
||||||
|
const TIMELINE_STEP_LIMIT = 6;
|
||||||
|
|
||||||
|
function getStartupErrorText(status: AppStartupStatus): string {
|
||||||
|
return status.error ? `Startup failed: ${status.error}` : 'Startup failed. Please restart.';
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
function formatDuration(ms: number): string {
|
||||||
<React.StrictMode>
|
const safeMs = Math.max(0, ms);
|
||||||
<App />
|
const seconds = Math.floor(safeMs / 1000);
|
||||||
</React.StrictMode>
|
if (seconds < 60) {
|
||||||
);
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const rest = seconds % 60;
|
||||||
|
return `${minutes}m ${rest.toString().padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentStartupStep(status: AppStartupStatus): AppStartupStep | null {
|
||||||
|
const steps = status.steps ?? [];
|
||||||
|
const active = [...steps].reverse().find((step) => !step.finishedAt);
|
||||||
|
return active ?? steps[steps.length - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStepElapsedMs(step: AppStartupStep | null, status: AppStartupStatus): number {
|
||||||
|
if (!step) {
|
||||||
|
return Date.now() - status.startedAt;
|
||||||
|
}
|
||||||
|
return step.finishedAt ? step.finishedAt - step.startedAt : Date.now() - step.startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlowStepHint(step: AppStartupStep | null, elapsedMs: number): string {
|
||||||
|
if (!step || step.finishedAt || elapsedMs < SLOW_STEP_MS) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = step.phase;
|
||||||
|
if (phase.includes('shell-env-login') || phase.includes('shell-env-interactive')) {
|
||||||
|
return elapsedMs >= VERY_SLOW_STEP_MS
|
||||||
|
? 'Shell startup is still running. Slow shell profile scripts can delay first launch.'
|
||||||
|
: 'Reading your shell PATH. This can take a few seconds on first launch.';
|
||||||
|
}
|
||||||
|
if (phase.includes('node-runtime')) {
|
||||||
|
return 'Checking Node.js for the local MCP server. This can wait up to 5 seconds.';
|
||||||
|
}
|
||||||
|
if (phase.includes('packaged-server-copy')) {
|
||||||
|
return 'Preparing the packaged MCP server copy. This should only happen after updates.';
|
||||||
|
}
|
||||||
|
if (phase.includes('path') || phase.includes('standard-locations') || phase.includes('nvm')) {
|
||||||
|
return 'Searching local runtime paths. A large PATH or slow disk can make this step longer.';
|
||||||
|
}
|
||||||
|
if (phase.includes('doctor')) {
|
||||||
|
return 'Using diagnostics fallback to locate the runtime.';
|
||||||
|
}
|
||||||
|
if (phase.includes('settings')) {
|
||||||
|
return 'Loading encrypted local settings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Still working on this startup step.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStartupTimeline(status: AppStartupStatus): void {
|
||||||
|
const timeline = document.getElementById('splash-timeline');
|
||||||
|
if (!timeline) return;
|
||||||
|
|
||||||
|
const steps = (status.steps ?? []).slice(-TIMELINE_STEP_LIMIT);
|
||||||
|
timeline.replaceChildren();
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
const isCurrent = !step.finishedAt && !status.ready && !status.error;
|
||||||
|
row.className = `splash-step${isCurrent ? ' is-current' : ''}`;
|
||||||
|
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'splash-step-dot';
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'splash-step-label';
|
||||||
|
label.textContent = step.message;
|
||||||
|
label.title = step.message;
|
||||||
|
|
||||||
|
const time = document.createElement('div');
|
||||||
|
time.className = 'splash-step-time';
|
||||||
|
time.textContent = formatDuration(getStepElapsedMs(step, status));
|
||||||
|
|
||||||
|
row.append(dot, label, time);
|
||||||
|
timeline.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStartupSplash(status: AppStartupStatus): void {
|
||||||
|
const splash = document.getElementById('splash');
|
||||||
|
const statusElement = document.getElementById('splash-status');
|
||||||
|
const elapsedElement = document.getElementById('splash-elapsed');
|
||||||
|
const hintElement = document.getElementById('splash-hint');
|
||||||
|
if (!splash || !statusElement) return;
|
||||||
|
|
||||||
|
latestStartupStatus = status;
|
||||||
|
const currentStep = getCurrentStartupStep(status);
|
||||||
|
const elapsedMs = getStepElapsedMs(currentStep, status);
|
||||||
|
const hint = getSlowStepHint(currentStep, elapsedMs);
|
||||||
|
|
||||||
|
splash.classList.toggle('splash-status-error', Boolean(status.error) && !status.ready);
|
||||||
|
splash.classList.toggle('splash-status-slow', Boolean(hint) && !status.error && !status.ready);
|
||||||
|
statusElement.textContent =
|
||||||
|
status.error && !status.ready
|
||||||
|
? getStartupErrorText(status)
|
||||||
|
: (currentStep?.message ?? status.message);
|
||||||
|
if (elapsedElement) {
|
||||||
|
elapsedElement.textContent = formatDuration(elapsedMs);
|
||||||
|
}
|
||||||
|
if (hintElement) {
|
||||||
|
hintElement.textContent = status.error || status.ready ? '' : hint;
|
||||||
|
}
|
||||||
|
renderStartupTimeline(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStartupTicker(): void {
|
||||||
|
if (startupTicker !== undefined) return;
|
||||||
|
startupTicker = window.setInterval(() => {
|
||||||
|
if (latestStartupStatus) {
|
||||||
|
updateStartupSplash(latestStartupStatus);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStartupTicker(): void {
|
||||||
|
if (startupTicker === undefined) return;
|
||||||
|
window.clearInterval(startupTicker);
|
||||||
|
startupTicker = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountApp(): void {
|
||||||
|
if (root) return;
|
||||||
|
|
||||||
|
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev,
|
||||||
|
// which can start duplicate IPC init chains. Make initialization a one-time
|
||||||
|
// module-level side effect guarded by a global flag.
|
||||||
|
if (!window.__claudeTeamsUiDidInit) {
|
||||||
|
window.__claudeTeamsUiDidInit = true;
|
||||||
|
initializeNotificationListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
root = ReactDOM.createRoot(document.getElementById('root')!);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapRenderer(): Promise<void> {
|
||||||
|
const startupApi = window.electronAPI?.startup;
|
||||||
|
if (!startupApi) {
|
||||||
|
mountApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanup = (): void => undefined;
|
||||||
|
try {
|
||||||
|
let finished = false;
|
||||||
|
const handleStartupStatus = (nextStatus: AppStartupStatus): void => {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateStartupSplash(nextStatus);
|
||||||
|
if (nextStatus.ready) {
|
||||||
|
finished = true;
|
||||||
|
cleanup();
|
||||||
|
stopStartupTicker();
|
||||||
|
mountApp();
|
||||||
|
} else if (nextStatus.error) {
|
||||||
|
finished = true;
|
||||||
|
cleanup();
|
||||||
|
stopStartupTicker();
|
||||||
|
} else {
|
||||||
|
startStartupTicker();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cleanup = startupApi.onProgress(handleStartupStatus);
|
||||||
|
handleStartupStatus(await startupApi.getStatus());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[startup] status bridge unavailable: ${String(error)}`);
|
||||||
|
cleanup();
|
||||||
|
stopStartupTicker();
|
||||||
|
mountApp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrapRenderer();
|
||||||
|
|
|
||||||
|
|
@ -2239,7 +2239,57 @@ function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
||||||
}
|
}
|
||||||
normalizedAssignments[stableOwnerId] = assignment;
|
normalizedAssignments[stableOwnerId] = assignment;
|
||||||
}
|
}
|
||||||
return normalizedAssignments;
|
return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacySixRowOrbitAssignments(
|
||||||
|
assignments: TeamGraphSlotAssignments,
|
||||||
|
visibleOwnerIds: readonly string[]
|
||||||
|
): TeamGraphSlotAssignments {
|
||||||
|
if (visibleOwnerIds.length !== 6) {
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => {
|
||||||
|
const assignment = assignments[stableOwnerId];
|
||||||
|
return assignment ? [assignment] : [];
|
||||||
|
});
|
||||||
|
const hasLegacyTwoRowBottomMarker = visibleAssignments.some(
|
||||||
|
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
|
||||||
|
);
|
||||||
|
let changed = false;
|
||||||
|
const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||||
|
|
||||||
|
for (const stableOwnerId of visibleOwnerIds) {
|
||||||
|
const assignment = normalizedAssignments[stableOwnerId];
|
||||||
|
if (!assignment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasLegacyTwoRowBottomMarker &&
|
||||||
|
assignment.ringIndex === 1 &&
|
||||||
|
assignment.sectorIndex >= 0 &&
|
||||||
|
assignment.sectorIndex < 3
|
||||||
|
) {
|
||||||
|
normalizedAssignments[stableOwnerId] = {
|
||||||
|
ringIndex: 2,
|
||||||
|
sectorIndex: assignment.sectorIndex,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) {
|
||||||
|
normalizedAssignments[stableOwnerId] = {
|
||||||
|
ringIndex: 2,
|
||||||
|
sectorIndex: assignment.sectorIndex - 3,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? normalizedAssignments : assignments;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
function pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,47 @@ export function deriveReviewActivityTimerAnchor(
|
||||||
const memberKey = normalizeMemberName(params.memberName);
|
const memberKey = normalizeMemberName(params.memberName);
|
||||||
if (!memberKey) return null;
|
if (!memberKey) return null;
|
||||||
|
|
||||||
|
const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : [];
|
||||||
|
for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) {
|
||||||
|
const interval = reviewIntervals[index];
|
||||||
|
if (normalizeMemberName(interval?.reviewer) !== memberKey || interval?.completedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const startedAtMs = parseIsoMs(interval.startedAt);
|
||||||
|
if (startedAtMs <= 0) return null;
|
||||||
|
|
||||||
|
const cycleStartedAtMs = getCurrentReviewCycleStartedAtMs(task, startedAtMs);
|
||||||
|
let baseElapsedMs = 0;
|
||||||
|
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
|
||||||
|
const previous = reviewIntervals[previousIndex];
|
||||||
|
if (normalizeMemberName(previous?.reviewer) !== memberKey) continue;
|
||||||
|
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
|
||||||
|
const previousCompletedAtMs = parseIsoMs(previous?.completedAt);
|
||||||
|
if (
|
||||||
|
previousStartedAtMs >= cycleStartedAtMs &&
|
||||||
|
previousStartedAtMs > 0 &&
|
||||||
|
previousCompletedAtMs > previousStartedAtMs
|
||||||
|
) {
|
||||||
|
baseElapsedMs += previousCompletedAtMs - previousStartedAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startedAt: interval.startedAt,
|
||||||
|
startedAtMs,
|
||||||
|
baseElapsedMs,
|
||||||
|
timerId: createMemberActivityTimerId({
|
||||||
|
teamName: params.teamName,
|
||||||
|
memberName: params.memberName,
|
||||||
|
phase: 'review',
|
||||||
|
taskId: task.id,
|
||||||
|
startedAt: interval.startedAt,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewIntervals.length > 0) return null;
|
||||||
|
|
||||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||||
const event = events[index];
|
const event = events[index];
|
||||||
|
|
@ -369,6 +410,27 @@ export function deriveReviewActivityTimerAnchor(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentReviewCycleStartedAtMs(task: TeamTaskWithKanban, fallbackMs: number): number {
|
||||||
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||||
|
const event = events[index];
|
||||||
|
if (event.type === 'review_started') {
|
||||||
|
const startedAtMs = parseIsoMs(event.timestamp);
|
||||||
|
return startedAtMs > 0 ? startedAtMs : fallbackMs;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.type === 'review_approved' ||
|
||||||
|
event.type === 'review_changes_requested' ||
|
||||||
|
event.type === 'task_created' ||
|
||||||
|
(event.type === 'status_changed' &&
|
||||||
|
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted'))
|
||||||
|
) {
|
||||||
|
return fallbackMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function resetMemberActivityTimerStoreForTests(): void {
|
export function resetMemberActivityTimerStoreForTests(): void {
|
||||||
timers.clear();
|
timers.clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000;
|
const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000;
|
||||||
|
export const MEMBER_STARTING_STALE_AFTER_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
function isLaunchStillStarting(
|
function isLaunchStillStarting(
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
|
|
@ -634,6 +635,7 @@ export type MemberLaunchVisualState =
|
||||||
| 'queued'
|
| 'queued'
|
||||||
| 'waiting'
|
| 'waiting'
|
||||||
| 'spawning'
|
| 'spawning'
|
||||||
|
| 'starting_stale'
|
||||||
| 'permission_pending'
|
| 'permission_pending'
|
||||||
| 'bootstrap_stalled'
|
| 'bootstrap_stalled'
|
||||||
| 'runtime_pending'
|
| 'runtime_pending'
|
||||||
|
|
@ -666,6 +668,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
||||||
return 'waiting to start';
|
return 'waiting to start';
|
||||||
case 'spawning':
|
case 'spawning':
|
||||||
return 'starting';
|
return 'starting';
|
||||||
|
case 'starting_stale':
|
||||||
|
return 'starting stale';
|
||||||
case 'permission_pending':
|
case 'permission_pending':
|
||||||
return 'awaiting permission';
|
return 'awaiting permission';
|
||||||
case 'bootstrap_stalled':
|
case 'bootstrap_stalled':
|
||||||
|
|
@ -700,6 +704,8 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
|
||||||
case 'runtime_pending':
|
case 'runtime_pending':
|
||||||
case 'runtime_candidate':
|
case 'runtime_candidate':
|
||||||
return 'bg-amber-400 animate-pulse';
|
return 'bg-amber-400 animate-pulse';
|
||||||
|
case 'starting_stale':
|
||||||
|
return 'bg-amber-400';
|
||||||
case 'registered_only':
|
case 'registered_only':
|
||||||
return SPAWN_DOT_COLORS.waiting;
|
return SPAWN_DOT_COLORS.waiting;
|
||||||
case 'shell_only':
|
case 'shell_only':
|
||||||
|
|
@ -794,6 +800,41 @@ function hasElapsedSinceIso(
|
||||||
return Number.isFinite(parsed) && nowMs - parsed >= thresholdMs;
|
return Number.isFinite(parsed) && nowMs - parsed >= thresholdMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMemberStartingStale({
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnFirstSpawnAcceptedAt,
|
||||||
|
spawnUpdatedAt,
|
||||||
|
nowMs = Date.now(),
|
||||||
|
}: {
|
||||||
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
spawnLaunchState?: MemberLaunchState;
|
||||||
|
spawnFirstSpawnAcceptedAt?: string;
|
||||||
|
spawnUpdatedAt?: string;
|
||||||
|
nowMs?: number;
|
||||||
|
}): boolean {
|
||||||
|
if (
|
||||||
|
spawnLaunchState === 'failed_to_start' ||
|
||||||
|
spawnLaunchState === 'confirmed_alive' ||
|
||||||
|
spawnLaunchState === 'skipped_for_launch' ||
|
||||||
|
spawnLaunchState === 'runtime_pending_permission' ||
|
||||||
|
spawnStatus === 'error' ||
|
||||||
|
spawnStatus === 'online' ||
|
||||||
|
spawnStatus === 'skipped'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (spawnLaunchState !== 'starting' && spawnStatus !== 'waiting' && spawnStatus !== 'spawning') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasElapsedSinceIso(
|
||||||
|
spawnFirstSpawnAcceptedAt ?? spawnUpdatedAt,
|
||||||
|
MEMBER_STARTING_STALE_AFTER_MS,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasBootstrapStallDiagnostic(value: string | undefined): boolean {
|
function hasBootstrapStallDiagnostic(value: string | undefined): boolean {
|
||||||
const normalized = value?.trim().toLowerCase() ?? '';
|
const normalized = value?.trim().toLowerCase() ?? '';
|
||||||
return (
|
return (
|
||||||
|
|
@ -881,12 +922,15 @@ export function buildMemberLaunchPresentation({
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
spawnBootstrapConfirmed,
|
spawnBootstrapConfirmed,
|
||||||
spawnBootstrapStalled,
|
spawnBootstrapStalled,
|
||||||
|
spawnFirstSpawnAcceptedAt,
|
||||||
|
spawnUpdatedAt,
|
||||||
runtimeAdvisory,
|
runtimeAdvisory,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
isLaunchSettling = false,
|
isLaunchSettling = false,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
nowMs,
|
||||||
}: {
|
}: {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
spawnStatus: MemberSpawnStatus | undefined;
|
spawnStatus: MemberSpawnStatus | undefined;
|
||||||
|
|
@ -895,12 +939,15 @@ export function buildMemberLaunchPresentation({
|
||||||
spawnRuntimeAlive: boolean | undefined;
|
spawnRuntimeAlive: boolean | undefined;
|
||||||
spawnBootstrapConfirmed?: boolean;
|
spawnBootstrapConfirmed?: boolean;
|
||||||
spawnBootstrapStalled?: boolean;
|
spawnBootstrapStalled?: boolean;
|
||||||
|
spawnFirstSpawnAcceptedAt?: string;
|
||||||
|
spawnUpdatedAt?: string;
|
||||||
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
||||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
isLaunchSettling?: boolean;
|
isLaunchSettling?: boolean;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
|
nowMs?: number;
|
||||||
}): MemberLaunchPresentation {
|
}): MemberLaunchPresentation {
|
||||||
const hasConfirmedSpawnLaunch =
|
const hasConfirmedSpawnLaunch =
|
||||||
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
|
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
|
||||||
|
|
@ -943,6 +990,15 @@ export function buildMemberLaunchPresentation({
|
||||||
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId);
|
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId);
|
||||||
const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory);
|
const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory);
|
||||||
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
|
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
|
||||||
|
const startingIsStale =
|
||||||
|
!hasConfirmedSpawnLaunch &&
|
||||||
|
isMemberStartingStale({
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnFirstSpawnAcceptedAt,
|
||||||
|
spawnUpdatedAt,
|
||||||
|
nowMs,
|
||||||
|
});
|
||||||
|
|
||||||
let launchVisualState: MemberLaunchVisualState = null;
|
let launchVisualState: MemberLaunchVisualState = null;
|
||||||
if (isTeamAlive !== false || isTeamProvisioning) {
|
if (isTeamAlive !== false || isTeamProvisioning) {
|
||||||
|
|
@ -969,6 +1025,8 @@ export function buildMemberLaunchPresentation({
|
||||||
runtimeEntry?.livenessKind === 'not_found')
|
runtimeEntry?.livenessKind === 'not_found')
|
||||||
) {
|
) {
|
||||||
launchVisualState = 'stale_runtime';
|
launchVisualState = 'stale_runtime';
|
||||||
|
} else if (!hasConfirmedSpawnLaunch && startingIsStale) {
|
||||||
|
launchVisualState = 'starting_stale';
|
||||||
} else if (
|
} else if (
|
||||||
!hasConfirmedSpawnLaunch &&
|
!hasConfirmedSpawnLaunch &&
|
||||||
isQueuedOpenCodeLaunch(
|
isQueuedOpenCodeLaunch(
|
||||||
|
|
@ -1007,6 +1065,7 @@ export function buildMemberLaunchPresentation({
|
||||||
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
|
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
|
||||||
const shouldShowLaunchStatusAsPresence =
|
const shouldShowLaunchStatusAsPresence =
|
||||||
launchVisualState === 'queued' ||
|
launchVisualState === 'queued' ||
|
||||||
|
launchVisualState === 'starting_stale' ||
|
||||||
launchVisualState === 'permission_pending' ||
|
launchVisualState === 'permission_pending' ||
|
||||||
launchVisualState === 'bootstrap_stalled' ||
|
launchVisualState === 'bootstrap_stalled' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
|
|
@ -1023,7 +1082,9 @@ export function buildMemberLaunchPresentation({
|
||||||
const spawnBadgeLabel =
|
const spawnBadgeLabel =
|
||||||
spawnStatus && spawnStatus !== 'online'
|
spawnStatus && spawnStatus !== 'online'
|
||||||
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
||||||
? 'starting'
|
? startingIsStale
|
||||||
|
? 'starting stale'
|
||||||
|
: 'starting'
|
||||||
: spawnStatus
|
: spawnStatus
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
@ -1033,7 +1094,7 @@ export function buildMemberLaunchPresentation({
|
||||||
runtimeAdvisoryTone === 'error'
|
runtimeAdvisoryTone === 'error'
|
||||||
? STATUS_DOT_COLORS.terminated
|
? STATUS_DOT_COLORS.terminated
|
||||||
: (launchVisualStateDotClass ?? baseDotClass),
|
: (launchVisualStateDotClass ?? baseDotClass),
|
||||||
cardClass,
|
cardClass: launchVisualState === 'starting_stale' ? 'opacity-90' : cardClass,
|
||||||
runtimeAdvisoryLabel,
|
runtimeAdvisoryLabel,
|
||||||
runtimeAdvisoryTitle,
|
runtimeAdvisoryTitle,
|
||||||
runtimeAdvisoryTone,
|
runtimeAdvisoryTone,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export interface MemberLaunchDiagnosticsPayload {
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
|
memberCardError?: string;
|
||||||
launchState?: MemberLaunchState;
|
launchState?: MemberLaunchState;
|
||||||
spawnStatus?: MemberSpawnStatus;
|
spawnStatus?: MemberSpawnStatus;
|
||||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
|
|
@ -55,6 +56,15 @@ function boundedNumber(value: number | undefined): number | undefined {
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null {
|
||||||
|
const normalized = value
|
||||||
|
?.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
|
||||||
|
.replace(/^APIError\s*[-:]\s*/i, '');
|
||||||
|
return normalized && normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueDiagnostics(
|
function uniqueDiagnostics(
|
||||||
...groups: (readonly (string | undefined)[] | undefined)[]
|
...groups: (readonly (string | undefined)[] | undefined)[]
|
||||||
): string[] | undefined {
|
): string[] | undefined {
|
||||||
|
|
@ -91,7 +101,16 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
||||||
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
||||||
boundedString(spawnEntry?.hardFailureReason) ??
|
boundedString(spawnEntry?.hardFailureReason) ??
|
||||||
boundedString(spawnEntry?.error);
|
boundedString(spawnEntry?.error);
|
||||||
|
const memberCardError = boundedString(
|
||||||
|
normalizeMemberLaunchFailureReason(
|
||||||
|
spawnEntry?.error ??
|
||||||
|
spawnEntry?.hardFailureReason ??
|
||||||
|
spawnEntry?.runtimeDiagnostic ??
|
||||||
|
runtimeEntry?.runtimeDiagnostic
|
||||||
|
) ?? undefined
|
||||||
|
);
|
||||||
const diagnostics = uniqueDiagnostics(
|
const diagnostics = uniqueDiagnostics(
|
||||||
|
memberCardError ? [memberCardError] : undefined,
|
||||||
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
||||||
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
||||||
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
||||||
|
|
@ -103,6 +122,7 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
||||||
...(params.teamName ? { teamName: params.teamName } : {}),
|
...(params.teamName ? { teamName: params.teamName } : {}),
|
||||||
...(runId ? { runId } : {}),
|
...(runId ? { runId } : {}),
|
||||||
memberName: params.memberName,
|
memberName: params.memberName,
|
||||||
|
...(memberCardError ? { memberCardError } : {}),
|
||||||
...((spawnEntry?.launchState ?? params.launchState)
|
...((spawnEntry?.launchState ?? params.launchState)
|
||||||
? { launchState: spawnEntry?.launchState ?? params.launchState }
|
? { launchState: spawnEntry?.launchState ?? params.launchState }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
@ -161,6 +181,7 @@ export function hasMemberLaunchDiagnosticsDetails(
|
||||||
return Boolean(
|
return Boolean(
|
||||||
(payload.launchState && payload.launchState !== 'confirmed_alive') ||
|
(payload.launchState && payload.launchState !== 'confirmed_alive') ||
|
||||||
(payload.spawnStatus && payload.spawnStatus !== 'online') ||
|
(payload.spawnStatus && payload.spawnStatus !== 'online') ||
|
||||||
|
payload.memberCardError ||
|
||||||
payload.bootstrapStalled === true ||
|
payload.bootstrapStalled === true ||
|
||||||
weakLiveness ||
|
weakLiveness ||
|
||||||
payload.runtimeDiagnostic ||
|
payload.runtimeDiagnostic ||
|
||||||
|
|
@ -182,7 +203,12 @@ export function getMemberLaunchDiagnosticsErrorMessage(
|
||||||
if (!hasMemberLaunchDiagnosticsError(payload)) {
|
if (!hasMemberLaunchDiagnosticsError(payload)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed';
|
return (
|
||||||
|
payload.memberCardError ??
|
||||||
|
payload.runtimeDiagnostic ??
|
||||||
|
payload.diagnostics?.[0] ??
|
||||||
|
'Launch failed'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMemberLaunchDiagnosticsPayload(
|
export function formatMemberLaunchDiagnosticsPayload(
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,34 @@ export interface UpdaterAPI {
|
||||||
onStatus: (callback: (event: unknown, status: unknown) => void) => () => void;
|
onStatus: (callback: (event: unknown, status: unknown) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Startup API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface AppStartupStatus {
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
ready: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
startedAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
steps?: AppStartupStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppStartupStep {
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
startedAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
finishedAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppStartupAPI {
|
||||||
|
getStatus: () => Promise<AppStartupStatus>;
|
||||||
|
onProgress: (callback: (status: AppStartupStatus) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Context API
|
// Context API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -770,6 +798,7 @@ export interface ReviewAPI {
|
||||||
* Complete Electron API exposed to the renderer process via preload script.
|
* Complete Electron API exposed to the renderer process via preload script.
|
||||||
*/
|
*/
|
||||||
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
|
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
|
||||||
|
startup?: AppStartupAPI;
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
getProjects: () => Promise<Project[]>;
|
getProjects: () => Promise<Project[]>;
|
||||||
getSessions: (projectId: string) => Promise<Session[]>;
|
getSessions: (projectId: string) => Promise<Session[]>;
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,15 @@ export interface TaskWorkInterval {
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskReviewInterval {
|
||||||
|
/** Reviewer actively reviewing during this interval. */
|
||||||
|
reviewer: string;
|
||||||
|
/** ISO timestamp when reviewer started or resumed review. */
|
||||||
|
startedAt: string;
|
||||||
|
/** ISO timestamp when reviewer stopped, paused, approved, or requested changes. */
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task History Events — unified workflow event log
|
// Task History Events — unified workflow event log
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -470,6 +479,10 @@ export interface TeamTask {
|
||||||
* We persist intervals for reliable log attribution without relying on heuristics.
|
* We persist intervals for reliable log attribution without relying on heuristics.
|
||||||
*/
|
*/
|
||||||
workIntervals?: TaskWorkInterval[];
|
workIntervals?: TaskWorkInterval[];
|
||||||
|
/**
|
||||||
|
* Review work periods, split across runtime pauses/restarts just like workIntervals.
|
||||||
|
*/
|
||||||
|
reviewIntervals?: TaskReviewInterval[];
|
||||||
/**
|
/**
|
||||||
* Unified workflow event log.
|
* Unified workflow event log.
|
||||||
* Append-only — records task creation, status changes, and review transitions.
|
* Append-only — records task creation, status changes, and review transitions.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,16 @@ function isAnthropicHaikuModel(model: string): boolean {
|
||||||
return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-');
|
return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnthropicSonnetModel(model: string): boolean {
|
||||||
|
const baseModel = stripOneMillionSuffix(model);
|
||||||
|
return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStandardOnlyAnthropicModel(model: string): string {
|
||||||
|
const baseModel = stripOneMillionSuffix(model);
|
||||||
|
return isAnthropicHaikuModel(baseModel) || isAnthropicSonnetModel(baseModel) ? baseModel : model;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAvailableLaunchModels(
|
function normalizeAvailableLaunchModels(
|
||||||
availableLaunchModels: Iterable<string> | undefined
|
availableLaunchModels: Iterable<string> | undefined
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
|
|
@ -52,9 +62,10 @@ export function resolveAnthropicLaunchModel(params: {
|
||||||
if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) {
|
if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) {
|
||||||
const staticDefault = getAnthropicDefaultTeamModel(params.limitContext);
|
const staticDefault = getAnthropicDefaultTeamModel(params.limitContext);
|
||||||
const runtimeDefault = params.defaultLaunchModel?.trim() || null;
|
const runtimeDefault = params.defaultLaunchModel?.trim() || null;
|
||||||
|
const rawPreferredDefault = runtimeDefault || staticDefault;
|
||||||
const preferredDefault = params.limitContext
|
const preferredDefault = params.limitContext
|
||||||
? stripOneMillionSuffix(runtimeDefault || staticDefault) || staticDefault
|
? stripOneMillionSuffix(rawPreferredDefault) || staticDefault
|
||||||
: runtimeDefault || staticDefault;
|
: normalizeStandardOnlyAnthropicModel(rawPreferredDefault) || staticDefault;
|
||||||
if (availableModels.size === 0) {
|
if (availableModels.size === 0) {
|
||||||
return preferredDefault;
|
return preferredDefault;
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +85,11 @@ export function resolveAnthropicLaunchModel(params: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.limitContext || isAnthropicHaikuModel(baseModel)) {
|
if (
|
||||||
|
params.limitContext ||
|
||||||
|
isAnthropicHaikuModel(baseModel) ||
|
||||||
|
isAnthropicSonnetModel(baseModel)
|
||||||
|
) {
|
||||||
return baseModel;
|
return baseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,90 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignm
|
||||||
{ ringIndex: 0, sectorIndex: 2 },
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
{ ringIndex: 0, sectorIndex: 3 },
|
{ ringIndex: 0, sectorIndex: 3 },
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 4 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 5 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 2 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 1 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 2 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 1 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 2 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 2 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ringIndex: 0, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 0, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 1, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 2, sectorIndex: 2 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 0 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 1 },
|
||||||
|
{ ringIndex: 3, sectorIndex: 2 },
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
export function buildOrderedVisibleTeamGraphOwnerIds(
|
export function buildOrderedVisibleTeamGraphOwnerIds(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
{
|
||||||
|
"generatedAt": "2026-05-08T18:34:37.950Z",
|
||||||
|
"runsPerModel": 1,
|
||||||
|
"qualification": {
|
||||||
|
"minimumAverageScore": 80,
|
||||||
|
"minimumSuccessfulRuns": 1,
|
||||||
|
"minimumConsistencyScore": 85,
|
||||||
|
"requireNoHardFailures": true
|
||||||
|
},
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"model": "opencode/big-pickle",
|
||||||
|
"verdict": "tested-only",
|
||||||
|
"confidence": "low",
|
||||||
|
"qualified": false,
|
||||||
|
"readinessScore": 73,
|
||||||
|
"averageScore": 90,
|
||||||
|
"consistencyScore": 100,
|
||||||
|
"behavioralAverageScore": 90,
|
||||||
|
"minScore": 90,
|
||||||
|
"successfulRuns": 0,
|
||||||
|
"countedRuns": 1,
|
||||||
|
"hardFailures": 1,
|
||||||
|
"providerInfraFailures": 0,
|
||||||
|
"runtimeTransportFailures": 0,
|
||||||
|
"modelBehaviorFailures": 1,
|
||||||
|
"harnessFailures": 0,
|
||||||
|
"p50DurationMs": 124249,
|
||||||
|
"p95DurationMs": 124249,
|
||||||
|
"stagePassRates": {
|
||||||
|
"launchBootstrap": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"directReply": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"peerRelayAB": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"peerRelayBC": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"concurrentReplies": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"taskRefs": {
|
||||||
|
"passed": 0,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 0
|
||||||
|
},
|
||||||
|
"cleanTranscript": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"noDuplicateTokens": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"latencyStable": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskRefPassRates": {
|
||||||
|
"directReply": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"peerRelayAB": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"peerRelayBC": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
},
|
||||||
|
"concurrentBob": {
|
||||||
|
"passed": 0,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 0
|
||||||
|
},
|
||||||
|
"concurrentTom": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"protocolViolationTotals": {
|
||||||
|
"badMessages": 0,
|
||||||
|
"duplicateOrMissingTokens": 0,
|
||||||
|
"affectedRuns": 0
|
||||||
|
},
|
||||||
|
"stageFailureImpact": [
|
||||||
|
{
|
||||||
|
"stage": "taskRefs",
|
||||||
|
"failedRuns": 1,
|
||||||
|
"weightedLoss": 10,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 0,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "cleanTranscript",
|
||||||
|
"failedRuns": 0,
|
||||||
|
"weightedLoss": 0,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "concurrentReplies",
|
||||||
|
"failedRuns": 0,
|
||||||
|
"weightedLoss": 0,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "directReply",
|
||||||
|
"failedRuns": 0,
|
||||||
|
"weightedLoss": 0,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "latencyStable",
|
||||||
|
"failedRuns": 0,
|
||||||
|
"weightedLoss": 0,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "launchBootstrap",
|
||||||
|
"failedRuns": 0,
|
||||||
|
"weightedLoss": 0,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "noDuplicateTokens",
|
||||||
|
"failedRuns": 0,
|
||||||
|
"weightedLoss": 0,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "peerRelayAB",
|
||||||
|
"failedRuns": 0,
|
||||||
|
"weightedLoss": 0,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "peerRelayBC",
|
||||||
|
"failedRuns": 0,
|
||||||
|
"weightedLoss": 0,
|
||||||
|
"passRate": {
|
||||||
|
"passed": 1,
|
||||||
|
"total": 1,
|
||||||
|
"rate": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scoreStability": {
|
||||||
|
"sampleSize": 1,
|
||||||
|
"minScore": 90,
|
||||||
|
"maxScore": 90,
|
||||||
|
"spread": 0,
|
||||||
|
"standardDeviation": 0,
|
||||||
|
"consistencyScore": 100
|
||||||
|
},
|
||||||
|
"dominantFailureCategory": "model-behavior",
|
||||||
|
"recommendationBlockers": [
|
||||||
|
"successful runs 0 < 1",
|
||||||
|
"hard failures 1",
|
||||||
|
"model-behavior failures 1",
|
||||||
|
"highest weighted stage loss taskRefs=10",
|
||||||
|
"weakest taskRefs concurrentBob=0/1 (0%)"
|
||||||
|
],
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"runIndex": 1,
|
||||||
|
"passed": false,
|
||||||
|
"score": 90,
|
||||||
|
"countedForRecommendation": true,
|
||||||
|
"outcome": "behavioral-fail",
|
||||||
|
"failureCategory": "model-behavior",
|
||||||
|
"primaryFailure": null,
|
||||||
|
"durationMs": 124249,
|
||||||
|
"hardFailure": true,
|
||||||
|
"stageDurationsMs": {
|
||||||
|
"setup": 214,
|
||||||
|
"launchBootstrap": 23875,
|
||||||
|
"materializeTasks": 32,
|
||||||
|
"directReply": 11617,
|
||||||
|
"peerRelayAB": 27950,
|
||||||
|
"peerRelayBC": 25689,
|
||||||
|
"concurrentReplies": 25243,
|
||||||
|
"hygiene": 1
|
||||||
|
},
|
||||||
|
"stageFailures": {},
|
||||||
|
"taskRefChecks": {
|
||||||
|
"directReply": true,
|
||||||
|
"peerRelayAB": true,
|
||||||
|
"peerRelayBC": true,
|
||||||
|
"concurrentBob": false,
|
||||||
|
"concurrentTom": true
|
||||||
|
},
|
||||||
|
"protocolViolations": {
|
||||||
|
"badMessages": 0,
|
||||||
|
"duplicateOrMissingTokens": []
|
||||||
|
},
|
||||||
|
"stages": {
|
||||||
|
"launchBootstrap": true,
|
||||||
|
"directReply": true,
|
||||||
|
"peerRelayAB": true,
|
||||||
|
"peerRelayBC": true,
|
||||||
|
"concurrentReplies": true,
|
||||||
|
"taskRefs": false,
|
||||||
|
"cleanTranscript": true,
|
||||||
|
"noDuplicateTokens": true,
|
||||||
|
"latencyStable": true
|
||||||
|
},
|
||||||
|
"diagnostics": [
|
||||||
|
"runId=34e07fb0-df87-4419-be0c-0f5386847b23"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# OpenCode Model Gauntlet Results
|
||||||
|
|
||||||
|
Generated: 2026-05-08T18:34:37.950Z
|
||||||
|
|
||||||
|
Runs per model: 1
|
||||||
|
Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0
|
||||||
|
|
||||||
|
Provider-infra runs are reported separately and are not counted as model behavior. They still block a Recommended verdict until rerun succeeds.
|
||||||
|
|
||||||
|
Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC=15, concurrentReplies=15, taskRefs=10, cleanTranscript=10, noDuplicateTokens=5, latencyStable=5.
|
||||||
|
|
||||||
|
## Model Summary
|
||||||
|
|
||||||
|
| Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 |
|
||||||
|
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| `opencode/big-pickle` | Tested only | low | 73 | 100 | 0 | 90 | 90 | 1/1 | 0/1 | taskRefs 0/1 (0%) | concurrentBob 0/1 (0%) | model-behavior | successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%) | 0 | 0 | 1 | 0 | 124249ms | 124249ms |
|
||||||
|
|
||||||
|
## opencode/big-pickle
|
||||||
|
|
||||||
|
Readiness score: 73.
|
||||||
|
|
||||||
|
Score stability: consistency=100, min=90, max=90, spread=0, stdDev=0, samples=1.
|
||||||
|
|
||||||
|
Recommendation blockers: successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%).
|
||||||
|
|
||||||
|
Weighted stage impact: taskRefs:loss=10, failed=1, pass=0/1 (0%).
|
||||||
|
|
||||||
|
Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:1/1 (100%), taskRefs:0/1 (0%), cleanTranscript:1/1 (100%), noDuplicateTokens:1/1 (100%), latencyStable:1/1 (100%).
|
||||||
|
|
||||||
|
TaskRef pass rates: directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentBob:0/1 (0%), concurrentTom:1/1 (100%).
|
||||||
|
|
||||||
|
Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0.
|
||||||
|
|
||||||
|
| Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics |
|
||||||
|
| ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | behavioral-fail | model-behavior | 90 | yes | 124249ms | taskRefs | peerRelayAB:27950ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:fail, concurrentTom:ok | - | runId=34e07fb0-df87-4419-be0c-0f5386847b23 |
|
||||||
|
|
||||||
271
test/agent-graph/stableSlots.test.ts
Normal file
271
test/agent-graph/stableSlots.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildStableSlotLayoutSnapshot,
|
||||||
|
resolveNearestSlotAssignment,
|
||||||
|
type StableRect,
|
||||||
|
type StableSlotLayoutSnapshot,
|
||||||
|
validateStableSlotLayout,
|
||||||
|
} from '../../packages/agent-graph/src/layout/stableSlots';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GraphLayoutPort,
|
||||||
|
GraphNode,
|
||||||
|
GraphOwnerSlotAssignment,
|
||||||
|
} from '../../packages/agent-graph/src/ports/types';
|
||||||
|
|
||||||
|
function ownerNode(id: string, kind: 'lead' | 'member' = 'member'): GraphNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
label: id,
|
||||||
|
state: 'idle',
|
||||||
|
domainRef: {
|
||||||
|
kind,
|
||||||
|
teamName: 'test-team',
|
||||||
|
memberName: id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskNode(id: string, ownerId: string, index: number): GraphNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'task',
|
||||||
|
label: id,
|
||||||
|
state: 'idle',
|
||||||
|
ownerId,
|
||||||
|
taskStatus: index === 0 ? 'in_progress' : 'pending',
|
||||||
|
reviewState: index === 1 ? 'review' : 'none',
|
||||||
|
domainRef: {
|
||||||
|
kind: 'task',
|
||||||
|
teamName: 'test-team',
|
||||||
|
taskId: id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOwnerGraph(
|
||||||
|
ownerCount: number,
|
||||||
|
slotAssignments: Record<string, GraphOwnerSlotAssignment>
|
||||||
|
): { nodes: GraphNode[]; layout: GraphLayoutPort } {
|
||||||
|
const nodes: GraphNode[] = [ownerNode('lead', 'lead')];
|
||||||
|
const ownerOrder = Array.from({ length: ownerCount }, (_, index) => `member-${index}`);
|
||||||
|
|
||||||
|
ownerOrder.forEach((ownerId, ownerIndex) => {
|
||||||
|
nodes.push(ownerNode(ownerId));
|
||||||
|
|
||||||
|
for (let taskIndex = 0; taskIndex < 3; taskIndex += 1) {
|
||||||
|
nodes.push(taskNode(`task-${ownerIndex}-${taskIndex}`, ownerId, taskIndex));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
layout: {
|
||||||
|
version: 'stable-slots-v1',
|
||||||
|
mode: 'radial',
|
||||||
|
ownerOrder,
|
||||||
|
slotAssignments,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSixOwnerGraph(): { nodes: GraphNode[]; layout: GraphLayoutPort } {
|
||||||
|
return buildOwnerGraph(
|
||||||
|
6,
|
||||||
|
Object.fromEntries(
|
||||||
|
Array.from({ length: 6 }, (_, index) => [
|
||||||
|
`member-${index}`,
|
||||||
|
{ ringIndex: 0, sectorIndex: index },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRowOrbitGraph(
|
||||||
|
ownerCount: number,
|
||||||
|
rowCounts: readonly number[]
|
||||||
|
): {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
layout: GraphLayoutPort;
|
||||||
|
} {
|
||||||
|
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
|
||||||
|
let ownerIndex = 0;
|
||||||
|
rowCounts.forEach((columnCount, ringIndex) => {
|
||||||
|
for (let sectorIndex = 0; sectorIndex < columnCount; sectorIndex += 1) {
|
||||||
|
assignments[`member-${ownerIndex}`] = { ringIndex, sectorIndex };
|
||||||
|
ownerIndex += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return buildOwnerGraph(ownerCount, assignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(nodes: GraphNode[], layout: GraphLayoutPort): StableSlotLayoutSnapshot {
|
||||||
|
const snapshot = buildStableSlotLayoutSnapshot({
|
||||||
|
teamName: 'test-team',
|
||||||
|
nodes,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
expect(snapshot).not.toBeNull();
|
||||||
|
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||||
|
return snapshot!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectsOverlap(left: StableRect, right: StableRect): boolean {
|
||||||
|
return (
|
||||||
|
left.left < right.right &&
|
||||||
|
left.right > right.left &&
|
||||||
|
left.top < right.bottom &&
|
||||||
|
left.bottom > right.top
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowCounts(snapshot: StableSlotLayoutSnapshot): number[] {
|
||||||
|
const rowCounts = new Map<number, number>();
|
||||||
|
for (const frame of snapshot.memberSlotFrames) {
|
||||||
|
rowCounts.set(frame.ringIndex, (rowCounts.get(frame.ringIndex) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return Array.from(rowCounts.entries())
|
||||||
|
.sort(([left], [right]) => left - right)
|
||||||
|
.map(([, count]) => count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowWidths(snapshot: StableSlotLayoutSnapshot): number[] {
|
||||||
|
const rows = new Map<number, StableRect[]>();
|
||||||
|
for (const frame of snapshot.memberSlotFrames) {
|
||||||
|
rows.set(frame.ringIndex, [...(rows.get(frame.ringIndex) ?? []), frame.bounds]);
|
||||||
|
}
|
||||||
|
return Array.from(rows.entries())
|
||||||
|
.sort(([left], [right]) => left - right)
|
||||||
|
.map(([, rects]) => {
|
||||||
|
const left = Math.min(...rects.map((rect) => rect.left));
|
||||||
|
const right = Math.max(...rects.map((rect) => rect.right));
|
||||||
|
return right - left;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stable slot layout', () => {
|
||||||
|
it('packs six legacy radial owners into two row-orbit rows', () => {
|
||||||
|
const { nodes, layout } = buildSixOwnerGraph();
|
||||||
|
const snapshot = getSnapshot(nodes, layout);
|
||||||
|
|
||||||
|
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
|
||||||
|
expect(getRowCounts(snapshot)).toEqual([3, 3]);
|
||||||
|
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 2, 2, 2]);
|
||||||
|
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets six radial owners move into an empty lead-level side slot', () => {
|
||||||
|
const { nodes, layout } = buildSixOwnerGraph();
|
||||||
|
const snapshot = getSnapshot(nodes, layout);
|
||||||
|
const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!;
|
||||||
|
const targetOwnerX =
|
||||||
|
snapshot.runtimeCentralExclusion.left - 160 - currentFrame.bounds.width / 2;
|
||||||
|
|
||||||
|
const result = resolveNearestSlotAssignment({
|
||||||
|
ownerId: 'member-0',
|
||||||
|
ownerX: targetOwnerX,
|
||||||
|
ownerY: 0,
|
||||||
|
nodes,
|
||||||
|
snapshot,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
assignment: { ringIndex: 1, sectorIndex: 0 },
|
||||||
|
previewOwnerX: targetOwnerX,
|
||||||
|
previewOwnerY: 0,
|
||||||
|
});
|
||||||
|
expect(result?.displacedOwnerId).toBeUndefined();
|
||||||
|
|
||||||
|
const nextSnapshot = getSnapshot(nodes, {
|
||||||
|
...layout,
|
||||||
|
slotAssignments: {
|
||||||
|
...layout.slotAssignments,
|
||||||
|
'member-0': result!.assignment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(nextSnapshot.ownerSlotLayoutKind).toBe('row-orbit');
|
||||||
|
expect(nextSnapshot.memberSlotFrameByOwnerId.get('member-0')).toMatchObject({
|
||||||
|
ringIndex: 1,
|
||||||
|
sectorIndex: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses three grid columns for six owners in rows layout', () => {
|
||||||
|
const { nodes, layout } = buildSixOwnerGraph();
|
||||||
|
const snapshot = getSnapshot(nodes, {
|
||||||
|
...layout,
|
||||||
|
mode: 'grid-under-lead',
|
||||||
|
slotAssignments: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead');
|
||||||
|
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 1, 1, 1]);
|
||||||
|
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => {
|
||||||
|
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
|
||||||
|
const snapshot = getSnapshot(nodes, layout);
|
||||||
|
|
||||||
|
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
|
||||||
|
expect(getRowCounts(snapshot)).toEqual([3, 2, 3]);
|
||||||
|
|
||||||
|
const leadRowFrames = snapshot.memberSlotFrames.filter((frame) => frame.ringIndex === 1);
|
||||||
|
expect(leadRowFrames).toHaveLength(2);
|
||||||
|
for (const frame of leadRowFrames) {
|
||||||
|
expect(rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)).toBe(false);
|
||||||
|
if (frame.ownerX < 0) {
|
||||||
|
expect(frame.bounds.right).toBeLessThanOrEqual(snapshot.runtimeCentralExclusion.left - 160);
|
||||||
|
} else {
|
||||||
|
expect(frame.bounds.left).toBeGreaterThanOrEqual(
|
||||||
|
snapshot.runtimeCentralExclusion.right + 160
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packs twelve radial owners into four safe rows with no four-column row width', () => {
|
||||||
|
const { nodes, layout } = buildRowOrbitGraph(12, [3, 3, 3, 3]);
|
||||||
|
const snapshot = getSnapshot(nodes, layout);
|
||||||
|
|
||||||
|
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
|
||||||
|
expect(getRowCounts(snapshot)).toEqual([3, 3, 3, 3]);
|
||||||
|
|
||||||
|
const maxFrameWidth = Math.max(...snapshot.memberSlotFrames.map((frame) => frame.bounds.width));
|
||||||
|
const maxRowWidth = Math.max(...getRowWidths(snapshot));
|
||||||
|
expect(maxRowWidth).toBeLessThan(maxFrameWidth * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps with the nearest existing row-orbit slot while dragging', () => {
|
||||||
|
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
|
||||||
|
const snapshot = getSnapshot(nodes, layout);
|
||||||
|
const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!;
|
||||||
|
const targetFrame = snapshot.memberSlotFrameByOwnerId.get('member-4')!;
|
||||||
|
|
||||||
|
const result = resolveNearestSlotAssignment({
|
||||||
|
ownerId: 'member-0',
|
||||||
|
ownerX: targetFrame.ownerX,
|
||||||
|
ownerY: targetFrame.ownerY,
|
||||||
|
nodes,
|
||||||
|
snapshot,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
assignment: {
|
||||||
|
ringIndex: targetFrame.ringIndex,
|
||||||
|
sectorIndex: targetFrame.sectorIndex,
|
||||||
|
},
|
||||||
|
displacedOwnerId: 'member-4',
|
||||||
|
displacedAssignment: {
|
||||||
|
ringIndex: currentFrame.ringIndex,
|
||||||
|
sectorIndex: currentFrame.sectorIndex,
|
||||||
|
},
|
||||||
|
previewOwnerX: targetFrame.ownerX,
|
||||||
|
previewOwnerY: targetFrame.ownerY,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -108,10 +108,28 @@ describe('ProcessBootstrapTransportEvidence', () => {
|
||||||
|
|
||||||
expect(summary).not.toBeNull();
|
expect(summary).not.toBeNull();
|
||||||
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
|
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
|
||||||
'Bootstrap transport reached bootstrap prompt observed: prompt seen; waiting for bootstrap confirmation.'
|
'Bootstrap prompt has not been submitted yet. Last transport stage: bootstrap prompt observed: prompt seen.'
|
||||||
);
|
);
|
||||||
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
|
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
|
||||||
'Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: bootstrap prompt observed: prompt seen'
|
'Bootstrap prompt was not submitted before timeout. Last transport stage: bootstrap prompt observed: prompt seen'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes submitted bootstrap prompts from not-submitted transport timeouts', () => {
|
||||||
|
const summary = summarizeProcessBootstrapTransportEvents([
|
||||||
|
{
|
||||||
|
type: 'bootstrap_submitted',
|
||||||
|
timestamp: '2026-05-07T10:00:02.000Z',
|
||||||
|
detail: 'messageId=abc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary).not.toBeNull();
|
||||||
|
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
|
||||||
|
'Bootstrap prompt was submitted; waiting for bootstrap confirmation. Last transport stage: bootstrap submitted: messageId=abc.'
|
||||||
|
);
|
||||||
|
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
|
||||||
|
'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout. Last transport stage: bootstrap submitted: messageId=abc'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13734,7 +13734,7 @@ describe('TeamProvisioningService', () => {
|
||||||
runtimeDiagnosticSeverity: 'warning',
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
});
|
});
|
||||||
expect(result.statuses.jack?.runtimeDiagnostic).toContain(
|
expect(result.statuses.jack?.runtimeDiagnostic).toContain(
|
||||||
'Bootstrap transport reached bootstrap submit rejected'
|
'Bootstrap prompt has not been submitted yet. Last transport stage: bootstrap submit rejected'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
250
test/main/services/team/TeamTaskActivityIntervalService.test.ts
Normal file
250
test/main/services/team/TeamTaskActivityIntervalService.test.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
import { TeamTaskActivityIntervalService } from '../../../../src/main/services/team/TeamTaskActivityIntervalService';
|
||||||
|
|
||||||
|
let tempDir = '';
|
||||||
|
|
||||||
|
async function writeTask(teamName: string, task: Record<string, unknown>): Promise<void> {
|
||||||
|
const taskDir = path.join(tempDir, 'tasks', teamName);
|
||||||
|
const taskId = String(task.id);
|
||||||
|
await fs.mkdir(taskDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(taskDir, `${taskId}.json`), JSON.stringify(task, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTask(teamName: string, taskId: string): Promise<Record<string, unknown>> {
|
||||||
|
return JSON.parse(
|
||||||
|
await fs.readFile(path.join(tempDir, 'tasks', teamName, `${taskId}.json`), 'utf8')
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamTaskActivityIntervalService', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-task-activity-'));
|
||||||
|
setClaudeBasePathOverride(tempDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
if (tempDir) {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses all active work and review intervals for a team without changing task status', async () => {
|
||||||
|
await writeTask('alpha', {
|
||||||
|
id: 'task-1',
|
||||||
|
subject: 'Build',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||||
|
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:05:00.000Z' }],
|
||||||
|
historyEvents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
|
||||||
|
'alpha',
|
||||||
|
'2026-05-08T10:10:00.000Z'
|
||||||
|
);
|
||||||
|
const task = await readTask('alpha', 'task-1');
|
||||||
|
|
||||||
|
expect(result.changedTasks).toBe(1);
|
||||||
|
expect(task.status).toBe('in_progress');
|
||||||
|
expect(task.workIntervals).toEqual([
|
||||||
|
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' },
|
||||||
|
]);
|
||||||
|
expect(task.reviewIntervals).toEqual([
|
||||||
|
{
|
||||||
|
reviewer: 'alice',
|
||||||
|
startedAt: '2026-05-08T10:05:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:10:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses only the selected member work and review intervals', async () => {
|
||||||
|
await writeTask('alpha', {
|
||||||
|
id: 'bob-task',
|
||||||
|
subject: 'Bob',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||||
|
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:01:00.000Z' }],
|
||||||
|
historyEvents: [],
|
||||||
|
});
|
||||||
|
await writeTask('alpha', {
|
||||||
|
id: 'tom-task',
|
||||||
|
subject: 'Tom',
|
||||||
|
owner: 'tom',
|
||||||
|
status: 'in_progress',
|
||||||
|
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||||
|
reviewIntervals: [{ reviewer: 'bob', startedAt: '2026-05-08T10:02:00.000Z' }],
|
||||||
|
historyEvents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForMember(
|
||||||
|
'alpha',
|
||||||
|
'bob',
|
||||||
|
'2026-05-08T10:05:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.changedTasks).toBe(2);
|
||||||
|
expect((await readTask('alpha', 'bob-task')).workIntervals).toEqual([
|
||||||
|
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||||
|
]);
|
||||||
|
expect((await readTask('alpha', 'bob-task')).reviewIntervals).toEqual([
|
||||||
|
{ reviewer: 'alice', startedAt: '2026-05-08T10:01:00.000Z' },
|
||||||
|
]);
|
||||||
|
expect((await readTask('alpha', 'tom-task')).workIntervals).toEqual([
|
||||||
|
{ startedAt: '2026-05-08T10:00:00.000Z' },
|
||||||
|
]);
|
||||||
|
expect((await readTask('alpha', 'tom-task')).reviewIntervals).toEqual([
|
||||||
|
{
|
||||||
|
reviewer: 'bob',
|
||||||
|
startedAt: '2026-05-08T10:02:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:05:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes active work and current review intervals for the selected member', async () => {
|
||||||
|
await writeTask('alpha', {
|
||||||
|
id: 'task-1',
|
||||||
|
subject: 'Build',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
workIntervals: [
|
||||||
|
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||||
|
],
|
||||||
|
reviewIntervals: [
|
||||||
|
{
|
||||||
|
reviewer: 'bob',
|
||||||
|
startedAt: '2026-05-08T10:06:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:08:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
historyEvents: [
|
||||||
|
{
|
||||||
|
id: 'event-review-started',
|
||||||
|
type: 'review_started',
|
||||||
|
timestamp: '2026-05-08T10:06:00.000Z',
|
||||||
|
actor: 'bob',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember(
|
||||||
|
'alpha',
|
||||||
|
'bob',
|
||||||
|
'2026-05-08T10:20:00.000Z'
|
||||||
|
);
|
||||||
|
const task = await readTask('alpha', 'task-1');
|
||||||
|
|
||||||
|
expect(result.changedTasks).toBe(1);
|
||||||
|
expect(task.workIntervals).toEqual([
|
||||||
|
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||||
|
{ startedAt: '2026-05-08T10:20:00.000Z' },
|
||||||
|
]);
|
||||||
|
expect(task.reviewIntervals).toEqual([
|
||||||
|
{
|
||||||
|
reviewer: 'bob',
|
||||||
|
startedAt: '2026-05-08T10:06:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:08:00.000Z',
|
||||||
|
},
|
||||||
|
{ reviewer: 'bob', startedAt: '2026-05-08T10:20:00.000Z' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs stale open intervals using last runtime evidence plus a small grace window', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
|
||||||
|
await writeTask('alpha', {
|
||||||
|
id: 'task-1',
|
||||||
|
subject: 'Build',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||||
|
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:10:00.000Z' }],
|
||||||
|
historyEvents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha', {
|
||||||
|
version: 2,
|
||||||
|
teamName: 'alpha',
|
||||||
|
updatedAt: '2026-05-08T10:31:00.000Z',
|
||||||
|
launchPhase: 'active',
|
||||||
|
expectedMembers: ['bob', 'alice'],
|
||||||
|
members: {
|
||||||
|
bob: {
|
||||||
|
name: 'bob',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
runtimeLastSeenAt: '2026-05-08T10:30:00.000Z',
|
||||||
|
lastEvaluatedAt: '2026-05-08T10:31:00.000Z',
|
||||||
|
},
|
||||||
|
alice: {
|
||||||
|
name: 'alice',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
lastHeartbeatAt: '2026-05-08T10:20:00.000Z',
|
||||||
|
lastEvaluatedAt: '2026-05-08T10:31:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: { confirmedCount: 2, pendingCount: 0, failedCount: 0, runtimeAlivePendingCount: 0 },
|
||||||
|
teamLaunchState: 'clean_success',
|
||||||
|
});
|
||||||
|
const task = await readTask('alpha', 'task-1');
|
||||||
|
|
||||||
|
expect(result.changedTasks).toBe(1);
|
||||||
|
expect(task.workIntervals).toEqual([
|
||||||
|
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:30:05.000Z' },
|
||||||
|
]);
|
||||||
|
expect(task.reviewIntervals).toEqual([
|
||||||
|
{
|
||||||
|
reviewer: 'alice',
|
||||||
|
startedAt: '2026-05-08T10:10:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:20:05.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs stale open intervals near their start time when no runtime evidence exists', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
|
||||||
|
await writeTask('alpha', {
|
||||||
|
id: 'task-1',
|
||||||
|
subject: 'Build',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||||
|
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:10:00.000Z' }],
|
||||||
|
historyEvents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha');
|
||||||
|
const task = await readTask('alpha', 'task-1');
|
||||||
|
|
||||||
|
expect(result.changedTasks).toBe(1);
|
||||||
|
expect(task.workIntervals).toEqual([
|
||||||
|
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:00:05.000Z' },
|
||||||
|
]);
|
||||||
|
expect(task.reviewIntervals).toEqual([
|
||||||
|
{
|
||||||
|
reviewer: 'alice',
|
||||||
|
startedAt: '2026-05-08T10:10:00.000Z',
|
||||||
|
completedAt: '2026-05-08T10:10:05.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
} from '../../../src/main/utils/electronUserDataMigration';
|
} from '../../../src/main/utils/electronUserDataMigration';
|
||||||
|
|
||||||
class FakeElectronApp implements ElectronUserDataMigrationApp {
|
class FakeElectronApp implements ElectronUserDataMigrationApp {
|
||||||
setPathCalls: Array<{ name: string; value: string }> = [];
|
setPathCalls: { name: string; value: string }[] = [];
|
||||||
|
|
||||||
constructor(private userDataPath: string) {}
|
constructor(private userDataPath: string) {}
|
||||||
|
|
||||||
|
|
@ -74,9 +74,9 @@ describe('electron userData migration', () => {
|
||||||
const parentPath = path.dirname(currentPath);
|
const parentPath = path.dirname(currentPath);
|
||||||
|
|
||||||
expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([
|
expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([
|
||||||
|
path.join(parentPath, 'agent-teams-ai'),
|
||||||
path.join(parentPath, 'Claude Agent Teams UI'),
|
path.join(parentPath, 'Claude Agent Teams UI'),
|
||||||
path.join(parentPath, 'claude-agent-teams-ui'),
|
path.join(parentPath, 'claude-agent-teams-ui'),
|
||||||
path.join(parentPath, 'agent-teams-ai'),
|
|
||||||
path.join(parentPath, 'claude-devtools'),
|
path.join(parentPath, 'claude-devtools'),
|
||||||
path.join(parentPath, 'claude-code-context'),
|
path.join(parentPath, 'claude-code-context'),
|
||||||
]);
|
]);
|
||||||
|
|
@ -106,6 +106,34 @@ describe('electron userData migration', () => {
|
||||||
expect(fs.existsSync(currentPath)).toBe(false);
|
expect(fs.existsSync(currentPath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not invoke the copy migration in the default startup strategy', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
const currentPath = path.join(root, 'agent-teams-ai');
|
||||||
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
|
||||||
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
|
const result = migrateElectronUserDataDirectory(app, {
|
||||||
|
copyDirectory: () => {
|
||||||
|
throw new Error('copy should not run during default startup');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath,
|
||||||
|
legacyPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: legacyPath },
|
||||||
|
{ name: 'sessionData', value: legacyPath },
|
||||||
|
]);
|
||||||
|
expect(fs.existsSync(currentPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not treat a cache-only new userData directory as populated', () => {
|
it('does not treat a cache-only new userData directory as populated', () => {
|
||||||
const root = createTempRoot();
|
const root = createTempRoot();
|
||||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
|
@ -132,6 +160,137 @@ describe('electron userData migration', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat Electron-generated shell files as populated new userData', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
const currentPath = path.join(root, 'agent-teams-ai');
|
||||||
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
|
||||||
|
writeFile(currentPath, 'Preferences', '{}');
|
||||||
|
writeFile(currentPath, 'Cookies', 'sqlite bytes');
|
||||||
|
writeFile(currentPath, 'DIPS', 'tracking state');
|
||||||
|
writeFile(currentPath, 'WebStorage/QuotaManager', 'quota');
|
||||||
|
writeFile(currentPath, '.updaterId', 'updater');
|
||||||
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath,
|
||||||
|
legacyPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: legacyPath },
|
||||||
|
{ name: 'sessionData', value: legacyPath },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat regenerated runtime-only folders as completed migration evidence', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
const currentPath = path.join(root, 'agent-teams-ai');
|
||||||
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
|
||||||
|
writeFile(currentPath, 'opencode-bridge/production-e2e-evidence.json', '{}');
|
||||||
|
writeFile(currentPath, 'mcp-server/1.3.0/index.js', 'console.log("generated")');
|
||||||
|
writeFile(currentPath, 'mcp-configs/agent-teams-mcp-generated.json', '{}');
|
||||||
|
writeFile(currentPath, 'Local Storage/leveldb/000003.log', 'renderer local storage');
|
||||||
|
writeFile(currentPath, 'IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log', 'idb');
|
||||||
|
writeFile(currentPath, 'Partitions/dev/Local Storage/leveldb/000003.log', 'partition state');
|
||||||
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath,
|
||||||
|
legacyPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: legacyPath },
|
||||||
|
{ name: 'sessionData', value: legacyPath },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a populated new userData directory after a completed migration', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
const currentPath = path.join(root, 'agent-teams-ai');
|
||||||
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
|
||||||
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current');
|
||||||
|
writeFile(currentPath, 'backups/registry.json', '{}');
|
||||||
|
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath,
|
||||||
|
legacyPath: null,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'current-populated',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers an already populated agent-teams-ai directory over older legacy data', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const completedNewPath = path.join(root, 'agent-teams-ai');
|
||||||
|
const olderLegacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
const currentPath = path.join(root, 'Agent Teams UI');
|
||||||
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
|
||||||
|
writeFile(currentPath, 'opencode-bridge/production-e2e-evidence.json', '{}');
|
||||||
|
writeFile(completedNewPath, 'data/attachments/team-a/current.txt', 'current');
|
||||||
|
writeFile(olderLegacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath,
|
||||||
|
legacyPath: completedNewPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: completedNewPath },
|
||||||
|
{ name: 'sessionData', value: completedNewPath },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses populated agent-teams-ai when both current product-name and new package-name paths exist', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const completedNewPath = path.join(root, 'agent-teams-ai');
|
||||||
|
const currentProductPath = path.join(root, 'Agent Teams UI');
|
||||||
|
const app = new FakeElectronApp(currentProductPath);
|
||||||
|
|
||||||
|
writeFile(currentProductPath, 'data/attachments/team-a/old.txt', 'old');
|
||||||
|
writeFile(completedNewPath, 'data/attachments/team-a/current.txt', 'current');
|
||||||
|
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath: currentProductPath,
|
||||||
|
legacyPath: completedNewPath,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-reused',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([
|
||||||
|
{ name: 'userData', value: completedNewPath },
|
||||||
|
{ name: 'sessionData', value: completedNewPath },
|
||||||
|
]);
|
||||||
|
expect(readFile(completedNewPath, 'data/attachments/team-a/current.txt')).toBe('current');
|
||||||
|
expect(readFile(currentProductPath, 'data/attachments/team-a/old.txt')).toBe('old');
|
||||||
|
});
|
||||||
|
|
||||||
it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => {
|
it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => {
|
||||||
const root = createTempRoot();
|
const root = createTempRoot();
|
||||||
const legacyPath = path.join(root, 'Claude Agent Teams UI');
|
const legacyPath = path.join(root, 'Claude Agent Teams UI');
|
||||||
|
|
@ -383,12 +542,12 @@ describe('electron userData migration', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the lowercase package-name legacy directory when product-name legacy data is absent', () => {
|
it('uses the lowercase package-name legacy directory when product-name durable data is absent', () => {
|
||||||
const root = createTempRoot();
|
const root = createTempRoot();
|
||||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
const currentPath = path.join(root, 'Agent Teams UI');
|
const currentPath = path.join(root, 'Agent Teams UI');
|
||||||
|
|
||||||
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
|
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||||
|
|
||||||
const app = new FakeElectronApp(currentPath);
|
const app = new FakeElectronApp(currentPath);
|
||||||
const result = migrateElectronUserDataDirectory(app);
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
@ -404,7 +563,31 @@ describe('electron userData migration', () => {
|
||||||
{ name: 'userData', value: legacyPath },
|
{ name: 'userData', value: legacyPath },
|
||||||
{ name: 'sessionData', value: legacyPath },
|
{ name: 'sessionData', value: legacyPath },
|
||||||
]);
|
]);
|
||||||
expect(fs.existsSync(path.join(currentPath, 'mcp-configs/legacy.json'))).toBe(false);
|
expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/legacy.txt'))).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse non-durable legacy directories when no durable user data exists', () => {
|
||||||
|
const root = createTempRoot();
|
||||||
|
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||||
|
const currentPath = path.join(root, 'Agent Teams UI');
|
||||||
|
|
||||||
|
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
|
||||||
|
writeFile(legacyPath, 'opencode-bridge/command-ledger.json', '{"commands":[]}');
|
||||||
|
writeFile(legacyPath, 'Local Storage/leveldb/000003.log', 'renderer local storage');
|
||||||
|
|
||||||
|
const app = new FakeElectronApp(currentPath);
|
||||||
|
const result = migrateElectronUserDataDirectory(app);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
currentPath,
|
||||||
|
legacyPath: null,
|
||||||
|
migrated: false,
|
||||||
|
fallbackToLegacy: false,
|
||||||
|
reason: 'legacy-missing',
|
||||||
|
});
|
||||||
|
expect(app.setPathCalls).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers populated older legacy data over an empty newer legacy directory', () => {
|
it('prefers populated older legacy data over an empty newer legacy directory', () => {
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,12 @@ describe('formatTeamModelSummary', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('computeEffectiveTeamModel', () => {
|
describe('computeEffectiveTeamModel', () => {
|
||||||
it('appends [1m] for anthropic models', () => {
|
it('appends [1m] for Opus but keeps Sonnet on standard context', () => {
|
||||||
expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]');
|
expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]');
|
||||||
expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]');
|
expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet');
|
||||||
|
expect(computeEffectiveTeamModel('claude-sonnet-4-6', false, 'anthropic')).toBe(
|
||||||
|
'claude-sonnet-4-6'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => {
|
it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => {
|
||||||
|
|
@ -177,7 +180,7 @@ describe('computeEffectiveTeamModel', () => {
|
||||||
|
|
||||||
it('does not double-append [1m] when input already has it', () => {
|
it('does not double-append [1m] when input already has it', () => {
|
||||||
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
|
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||||
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]');
|
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet');
|
||||||
expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]');
|
expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -185,6 +188,56 @@ describe('computeEffectiveTeamModel', () => {
|
||||||
expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]');
|
expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a Sonnet runtime default on standard context', () => {
|
||||||
|
expect(
|
||||||
|
computeEffectiveTeamModel('', false, 'anthropic', {
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelCatalog: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
providerId: 'anthropic',
|
||||||
|
source: 'anthropic-models-api',
|
||||||
|
status: 'ready',
|
||||||
|
fetchedAt: '2026-04-21T00:00:00.000Z',
|
||||||
|
staleAt: '2026-04-21T00:10:00.000Z',
|
||||||
|
defaultModelId: 'sonnet[1m]',
|
||||||
|
defaultLaunchModel: 'sonnet[1m]',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'sonnet',
|
||||||
|
launchModel: 'sonnet',
|
||||||
|
displayName: 'Sonnet 4.6',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text', 'image'],
|
||||||
|
supportsPersonality: false,
|
||||||
|
isDefault: true,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'anthropic-models-api',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sonnet[1m]',
|
||||||
|
launchModel: 'sonnet[1m]',
|
||||||
|
displayName: 'Sonnet 4.6 (1M)',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text', 'image'],
|
||||||
|
supportsPersonality: false,
|
||||||
|
isDefault: false,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'anthropic-models-api',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: {
|
||||||
|
configReadState: 'ready',
|
||||||
|
appServerState: 'healthy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe('sonnet');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns base model without [1m] when limitContext is true', () => {
|
it('returns base model without [1m] when limitContext is true', () => {
|
||||||
expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus');
|
expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus');
|
||||||
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
|
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
|
||||||
|
|
|
||||||
|
|
@ -975,10 +975,10 @@ describe('TeamModelSelector disabled Codex models', () => {
|
||||||
onValueChange: () => undefined,
|
onValueChange: () => undefined,
|
||||||
providerDisabledReasonById: {
|
providerDisabledReasonById: {
|
||||||
opencode:
|
opencode:
|
||||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.',
|
||||||
},
|
},
|
||||||
providerDisabledBadgeLabelById: {
|
providerDisabledBadgeLabelById: {
|
||||||
opencode: 'side lane',
|
opencode: 'team only',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -990,9 +990,9 @@ describe('TeamModelSelector disabled Codex models', () => {
|
||||||
);
|
);
|
||||||
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
|
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
|
||||||
expect(openCodeButton?.getAttribute('title')).toBe(
|
expect(openCodeButton?.getAttribute('title')).toBe(
|
||||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.'
|
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.'
|
||||||
);
|
);
|
||||||
expect(openCodeButton?.textContent).toContain('side lane');
|
expect(openCodeButton?.textContent).toContain('team only');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
||||||
name: draft.name,
|
name: draft.name,
|
||||||
role: draft.customRole || undefined,
|
role: draft.customRole || undefined,
|
||||||
workflow: draft.workflow,
|
workflow: draft.workflow,
|
||||||
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | undefined,
|
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined,
|
||||||
providerBackendId: draft.providerBackendId as 'codex-native' | undefined,
|
providerBackendId: draft.providerBackendId as 'codex-native' | undefined,
|
||||||
model: draft.model,
|
model: draft.model,
|
||||||
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
||||||
|
|
@ -170,8 +170,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
||||||
fastMode: member.fastMode,
|
fastMode: member.fastMode,
|
||||||
})),
|
})),
|
||||||
filterEditableMemberInputs: (members: unknown) => members,
|
filterEditableMemberInputs: (members: unknown) => members,
|
||||||
normalizeLeadProviderForMode: (providerId: unknown) =>
|
normalizeLeadProviderForMode: (providerId: unknown) => providerId,
|
||||||
providerId === 'opencode' ? 'anthropic' : providerId,
|
|
||||||
normalizeMemberDraftForProviderMode: (member: unknown) => member,
|
normalizeMemberDraftForProviderMode: (member: unknown) => member,
|
||||||
normalizeProviderForMode: (providerId: unknown) => providerId,
|
normalizeProviderForMode: (providerId: unknown) => providerId,
|
||||||
validateMemberNameInline: () => null,
|
validateMemberNameInline: () => null,
|
||||||
|
|
@ -385,9 +384,9 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
||||||
computeEffectiveTeamModel: (model: string) => model || undefined,
|
computeEffectiveTeamModel: (model: string) => model || undefined,
|
||||||
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
|
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
|
||||||
[providerId, model, effort].filter(Boolean).join(' '),
|
[providerId, model, effort].filter(Boolean).join(' '),
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane',
|
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only',
|
||||||
OPENCODE_TEAM_LEAD_DISABLED_REASON:
|
OPENCODE_ONE_SHOT_DISABLED_REASON:
|
||||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
||||||
|
|
@ -745,7 +744,7 @@ describe('LaunchTeamDialog', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes saved OpenCode lead hydration away from the unsupported lead path', async () => {
|
it('launches a saved pure OpenCode team with OpenCode as the lead provider', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
vi.mocked(isTeamModelAvailableForUi).mockImplementation(
|
vi.mocked(isTeamModelAvailableForUi).mockImplementation(
|
||||||
(_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false
|
(_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false
|
||||||
|
|
@ -769,7 +768,7 @@ describe('LaunchTeamDialog', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as any;
|
} as any;
|
||||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValue({
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||||
teamName: 'team-alpha',
|
teamName: 'team-alpha',
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
model: 'opencode/minimax-m2.5-free',
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
|
@ -777,7 +776,8 @@ describe('LaunchTeamDialog', () => {
|
||||||
{
|
{
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
role: 'Reviewer',
|
role: 'Reviewer',
|
||||||
model: 'gemini-3-pro-preview',
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as any);
|
} as any);
|
||||||
|
|
@ -812,7 +812,7 @@ describe('LaunchTeamDialog', () => {
|
||||||
const opencodePrepareCalls = vi
|
const opencodePrepareCalls = vi
|
||||||
.mocked(runProviderPrepareDiagnostics)
|
.mocked(runProviderPrepareDiagnostics)
|
||||||
.mock.calls.filter((call) => call[0]?.providerId === 'opencode');
|
.mock.calls.filter((call) => call[0]?.providerId === 'opencode');
|
||||||
expect(opencodePrepareCalls).toHaveLength(0);
|
expect(opencodePrepareCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
(button) => button.textContent === 'Launch team'
|
(button) => button.textContent === 'Launch team'
|
||||||
|
|
@ -831,7 +831,8 @@ describe('LaunchTeamDialog', () => {
|
||||||
{
|
{
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
role: 'Reviewer',
|
role: 'Reviewer',
|
||||||
model: '',
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -840,9 +841,217 @@ describe('LaunchTeamDialog', () => {
|
||||||
onLaunch.mock.calls as Array<[{ providerId?: string; model?: string }]>
|
onLaunch.mock.calls as Array<[{ providerId?: string; model?: string }]>
|
||||||
)[0]?.[0] as { providerId?: string; model?: string } | undefined;
|
)[0]?.[0] as { providerId?: string; model?: string } | undefined;
|
||||||
expect(launchRequest).toMatchObject({
|
expect(launchRequest).toMatchObject({
|
||||||
providerId: 'anthropic',
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
});
|
});
|
||||||
expect(launchRequest?.model).not.toBe('opencode/minimax-m2.5-free');
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks OpenCode lead launch until a model is selected', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
storeState.cliStatus = {
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
verificationState: 'verified',
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
models: ['opencode/minimax-m2.5-free'],
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||||
|
teamName: 'team-alpha',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: '',
|
||||||
|
members: [{ name: 'alice', role: 'Reviewer', providerId: 'opencode' }],
|
||||||
|
} as any);
|
||||||
|
const onLaunch = vi.fn(async () => {});
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(LaunchTeamDialog, {
|
||||||
|
mode: 'launch',
|
||||||
|
open: true,
|
||||||
|
teamName: 'team-alpha',
|
||||||
|
members: [],
|
||||||
|
defaultProjectPath: '/tmp/project',
|
||||||
|
provisioningError: null,
|
||||||
|
clearProvisioningError: vi.fn(),
|
||||||
|
activeTeams: [],
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onLaunch,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('OpenCode lead requires a selected model.');
|
||||||
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent === 'Launch team'
|
||||||
|
);
|
||||||
|
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
||||||
|
expect(onLaunch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks OpenCode lead launch without an OpenCode teammate', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
storeState.cliStatus = {
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
verificationState: 'verified',
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
models: ['opencode/minimax-m2.5-free'],
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||||
|
teamName: 'team-alpha',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
members: [],
|
||||||
|
} as any);
|
||||||
|
const onLaunch = vi.fn(async () => {});
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(LaunchTeamDialog, {
|
||||||
|
mode: 'launch',
|
||||||
|
open: true,
|
||||||
|
teamName: 'team-alpha',
|
||||||
|
members: [],
|
||||||
|
defaultProjectPath: '/tmp/project',
|
||||||
|
provisioningError: null,
|
||||||
|
clearProvisioningError: vi.fn(),
|
||||||
|
activeTeams: [],
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onLaunch,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('OpenCode lead requires at least one OpenCode teammate.');
|
||||||
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent === 'Launch team'
|
||||||
|
);
|
||||||
|
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
||||||
|
expect(onLaunch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps OpenCode lead mixed-provider launches blocked', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
storeState.cliStatus = {
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
verificationState: 'verified',
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
models: ['opencode/minimax-m2.5-free'],
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerId: 'codex',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'codex_api_key',
|
||||||
|
verificationState: 'verified',
|
||||||
|
statusMessage: null,
|
||||||
|
detailMessage: null,
|
||||||
|
selectedBackendId: 'codex-native',
|
||||||
|
resolvedBackendId: 'codex-native',
|
||||||
|
models: ['gpt-5.4'],
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any;
|
||||||
|
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||||
|
teamName: 'team-alpha',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
members: [{ name: 'alice', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.4' }],
|
||||||
|
} as any);
|
||||||
|
const onLaunch = vi.fn(async () => {});
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(LaunchTeamDialog, {
|
||||||
|
mode: 'launch',
|
||||||
|
open: true,
|
||||||
|
teamName: 'team-alpha',
|
||||||
|
members: [],
|
||||||
|
defaultProjectPath: '/tmp/project',
|
||||||
|
provisioningError: null,
|
||||||
|
clearProvisioningError: vi.fn(),
|
||||||
|
activeTeams: [],
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onLaunch,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('OpenCode cannot lead mixed-provider teams');
|
||||||
|
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent === 'Launch team'
|
||||||
|
);
|
||||||
|
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
||||||
|
expect(onLaunch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
||||||
|
|
||||||
expect(result.blocksSubmission).toBe(true);
|
expect(result.blocksSubmission).toBe(true);
|
||||||
expect(result.title).toBe('OpenCode cannot lead mixed-provider teams');
|
expect(result.title).toBe('OpenCode cannot lead mixed-provider teams');
|
||||||
expect(result.message).toContain('OpenCode-led mixed teams are not supported');
|
expect(result.message).toContain('mixed teams cannot use OpenCode as the lead');
|
||||||
expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead');
|
expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
describe('members editor editable input filtering', () => {
|
describe('members editor editable input filtering', () => {
|
||||||
it('normalizes OpenCode away from the team lead while keeping other multimodel providers', () => {
|
it('keeps OpenCode available for the team lead only when multimodel is enabled', () => {
|
||||||
expect(normalizeLeadProviderForMode('opencode', true)).toBe('anthropic');
|
expect(normalizeLeadProviderForMode('opencode', true)).toBe('opencode');
|
||||||
expect(normalizeLeadProviderForMode('codex', true)).toBe('codex');
|
expect(normalizeLeadProviderForMode('codex', true)).toBe('codex');
|
||||||
expect(normalizeLeadProviderForMode('anthropic', true)).toBe('anthropic');
|
expect(normalizeLeadProviderForMode('anthropic', true)).toBe('anthropic');
|
||||||
expect(normalizeLeadProviderForMode('opencode', false)).toBe('anthropic');
|
expect(normalizeLeadProviderForMode('opencode', false)).toBe('anthropic');
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,9 @@ function restoreWindowAnimationFrame(): void {
|
||||||
originalWindowAnimationFrame.hasRequest
|
originalWindowAnimationFrame.hasRequest
|
||||||
? originalWindowAnimationFrame.requestAnimationFrame
|
? originalWindowAnimationFrame.requestAnimationFrame
|
||||||
: undefined,
|
: undefined,
|
||||||
originalWindowAnimationFrame.hasCancel ? originalWindowAnimationFrame.cancelAnimationFrame : undefined
|
originalWindowAnimationFrame.hasCancel
|
||||||
|
? originalWindowAnimationFrame.cancelAnimationFrame
|
||||||
|
: undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -524,9 +526,7 @@ describe('teamSlice actions', () => {
|
||||||
member: 'bob',
|
member: 'bob',
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
});
|
});
|
||||||
await store
|
await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
|
||||||
.getState()
|
|
||||||
.refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
|
|
||||||
|
|
||||||
expect(store.getState().sendMessageWarning).toBe(
|
expect(store.getState().sendMessageWarning).toBe(
|
||||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
|
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
|
||||||
|
|
@ -975,6 +975,51 @@ describe('teamSlice actions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes legacy six-owner row-orbit slots before preserving manual layout', () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
const members = [
|
||||||
|
{ name: 'alice', agentId: 'agent-alice' },
|
||||||
|
{ name: 'bob', agentId: 'agent-bob' },
|
||||||
|
{ name: 'tom', agentId: 'agent-tom' },
|
||||||
|
{ name: 'jack', agentId: 'agent-jack' },
|
||||||
|
{ name: 'nova', agentId: 'agent-nova' },
|
||||||
|
{ name: 'atlas', agentId: 'agent-atlas' },
|
||||||
|
];
|
||||||
|
store.setState({
|
||||||
|
slotAssignmentsByTeam: {
|
||||||
|
'my-team': {
|
||||||
|
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
'agent-atlas': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
'agent-bob': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
'agent-jack': { ringIndex: 1, sectorIndex: 0 },
|
||||||
|
'agent-nova': { ringIndex: 1, sectorIndex: 1 },
|
||||||
|
'agent-tom': { ringIndex: 1, sectorIndex: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graphLayoutSessionByTeam: {
|
||||||
|
'my-team': {
|
||||||
|
mode: 'manual',
|
||||||
|
signature: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store.getState().ensureTeamGraphSlotAssignments('my-team', members);
|
||||||
|
|
||||||
|
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||||
|
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
'agent-atlas': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
'agent-bob': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
'agent-jack': { ringIndex: 2, sectorIndex: 0 },
|
||||||
|
'agent-nova': { ringIndex: 2, sectorIndex: 1 },
|
||||||
|
'agent-tom': { ringIndex: 2, sectorIndex: 2 },
|
||||||
|
});
|
||||||
|
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
|
||||||
|
mode: 'manual',
|
||||||
|
signature: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
|
it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
@ -1352,10 +1397,7 @@ describe('teamSlice actions', () => {
|
||||||
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
|
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
|
||||||
|
|
||||||
expect(hoisted.getData).toHaveBeenCalledTimes(2);
|
expect(hoisted.getData).toHaveBeenCalledTimes(2);
|
||||||
expect(hoisted.getData.mock.calls[0]).toEqual([
|
expect(hoisted.getData.mock.calls[0]).toEqual(['my-team', { includeMemberBranches: false }]);
|
||||||
'my-team',
|
|
||||||
{ includeMemberBranches: false },
|
|
||||||
]);
|
|
||||||
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
|
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
|
||||||
|
|
||||||
thinRequest.resolve(thinSnapshot);
|
thinRequest.resolve(thinSnapshot);
|
||||||
|
|
@ -1414,7 +1456,9 @@ describe('teamSlice actions', () => {
|
||||||
|
|
||||||
hoisted.getData
|
hoisted.getData
|
||||||
.mockImplementationOnce(() => alphaThin.promise)
|
.mockImplementationOnce(() => alphaThin.promise)
|
||||||
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }))
|
.mockResolvedValueOnce(
|
||||||
|
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
|
||||||
|
)
|
||||||
.mockResolvedValueOnce(alphaFull);
|
.mockResolvedValueOnce(alphaFull);
|
||||||
|
|
||||||
const alphaSelect = store.getState().selectTeam('alpha-team');
|
const alphaSelect = store.getState().selectTeam('alpha-team');
|
||||||
|
|
@ -1427,7 +1471,9 @@ describe('teamSlice actions', () => {
|
||||||
|
|
||||||
await store.getState().selectTeam('beta-team');
|
await store.getState().selectTeam('beta-team');
|
||||||
|
|
||||||
alphaThin.resolve(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } }));
|
alphaThin.resolve(
|
||||||
|
createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } })
|
||||||
|
);
|
||||||
await alphaSelect;
|
await alphaSelect;
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
|
@ -1509,8 +1555,12 @@ describe('teamSlice actions', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
|
|
||||||
hoisted.getData
|
hoisted.getData
|
||||||
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } }))
|
.mockResolvedValueOnce(
|
||||||
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }));
|
createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } })
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
|
||||||
|
);
|
||||||
|
|
||||||
await store.getState().selectTeam('alpha-team');
|
await store.getState().selectTeam('alpha-team');
|
||||||
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
|
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
|
||||||
|
|
@ -3480,9 +3530,7 @@ describe('teamSlice actions', () => {
|
||||||
|
|
||||||
const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team');
|
const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team');
|
||||||
|
|
||||||
expect(result.failed).toEqual([
|
expect(result.failed).toEqual([{ memberName: 'alice', error: 'OpenRouter credits exhausted' }]);
|
||||||
{ memberName: 'alice', error: 'OpenRouter credits exhausted' },
|
|
||||||
]);
|
|
||||||
expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team');
|
expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team');
|
||||||
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
|
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
|
||||||
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
|
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,42 @@ describe('memberActivityTimer', () => {
|
||||||
).toBe('2026-05-07T09:35:00.000Z');
|
).toBe('2026-05-07T09:35:00.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('anchors review timers to persisted review intervals and adds paused review time', () => {
|
||||||
|
const task: TeamTaskWithKanban = {
|
||||||
|
...baseTask,
|
||||||
|
status: 'completed',
|
||||||
|
reviewState: 'review',
|
||||||
|
kanbanColumn: 'review',
|
||||||
|
reviewer: 'alice',
|
||||||
|
historyEvents: [
|
||||||
|
{
|
||||||
|
id: 'evt-1',
|
||||||
|
type: 'review_started',
|
||||||
|
from: 'review',
|
||||||
|
to: 'review',
|
||||||
|
actor: 'alice',
|
||||||
|
timestamp: '2026-05-07T09:30:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reviewIntervals: [
|
||||||
|
{
|
||||||
|
reviewer: 'alice',
|
||||||
|
startedAt: '2026-05-07T09:30:00.000Z',
|
||||||
|
completedAt: '2026-05-07T09:35:00.000Z',
|
||||||
|
},
|
||||||
|
{ reviewer: 'alice', startedAt: '2026-05-07T09:40:00.000Z' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const anchor = deriveReviewActivityTimerAnchor(task, {
|
||||||
|
teamName: 'alpha',
|
||||||
|
memberName: 'alice',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(anchor?.startedAt).toBe('2026-05-07T09:40:00.000Z');
|
||||||
|
expect(anchor?.baseElapsedMs).toBe(300_000);
|
||||||
|
});
|
||||||
|
|
||||||
it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => {
|
it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => {
|
||||||
const timerId = createMemberActivityTimerId({
|
const timerId = createMemberActivityTimerId({
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,30 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks long-running starting states as stale without making them failed', () => {
|
||||||
|
const presentation = buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'waiting',
|
||||||
|
spawnLaunchState: 'starting',
|
||||||
|
spawnLivenessSource: undefined,
|
||||||
|
spawnRuntimeAlive: false,
|
||||||
|
spawnUpdatedAt: '2026-05-08T12:00:00.000Z',
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: true,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
nowMs: Date.parse('2026-05-08T12:03:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(presentation.presenceLabel).toBe('starting stale');
|
||||||
|
expect(presentation.launchVisualState).toBe('starting_stale');
|
||||||
|
expect(presentation.launchStatusLabel).toBe('starting stale');
|
||||||
|
expect(presentation.dotClass).toContain('bg-amber-400');
|
||||||
|
expect(presentation.dotClass).not.toContain('animate-pulse');
|
||||||
|
expect(presentation.cardClass).not.toContain('member-waiting-shimmer');
|
||||||
|
expect(presentation.spawnBadgeLabel).toBe('starting stale');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps OpenCode runtime evidence states more specific than queued', () => {
|
it('keeps OpenCode runtime evidence states more specific than queued', () => {
|
||||||
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };
|
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
buildMemberLaunchDiagnosticsPayload,
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
formatMemberLaunchDiagnosticsPayload,
|
formatMemberLaunchDiagnosticsPayload,
|
||||||
hasMemberLaunchDiagnosticsDetails,
|
hasMemberLaunchDiagnosticsDetails,
|
||||||
|
getMemberLaunchDiagnosticsErrorMessage,
|
||||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
|
|
||||||
describe('member launch diagnostics', () => {
|
describe('member launch diagnostics', () => {
|
||||||
|
|
@ -62,4 +63,31 @@ describe('member launch diagnostics', () => {
|
||||||
expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true);
|
expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true);
|
||||||
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"livenessKind": "shell_only"');
|
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"livenessKind": "shell_only"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes the exact normalized member card error in copy diagnostics', () => {
|
||||||
|
const payload = buildMemberLaunchDiagnosticsPayload({
|
||||||
|
memberName: 'jack',
|
||||||
|
spawnEntry: {
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason:
|
||||||
|
'Latest assistant message msg_123 failed with APIError - OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys',
|
||||||
|
runtimeDiagnostic: 'persisted runtime pid is not alive',
|
||||||
|
runtimeDiagnosticSeverity: 'error',
|
||||||
|
updatedAt: '2026-05-08T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.memberCardError).toBe(
|
||||||
|
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
|
||||||
|
);
|
||||||
|
expect(payload.diagnostics?.[0]).toBe(
|
||||||
|
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
|
||||||
|
);
|
||||||
|
expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBe(
|
||||||
|
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
|
||||||
|
);
|
||||||
|
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -47,16 +47,31 @@ describe('resolveAnthropicLaunchModel', () => {
|
||||||
availableLaunchModels: ['opus', 'opus[1m]'],
|
availableLaunchModels: ['opus', 'opus[1m]'],
|
||||||
})
|
})
|
||||||
).toBe('opus');
|
).toBe('opus');
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves limitContext requests and never manufactures 1M Haiku variants', () => {
|
|
||||||
expect(
|
expect(
|
||||||
resolveAnthropicLaunchModel({
|
resolveAnthropicLaunchModel({
|
||||||
selectedModel: 'sonnet',
|
selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION,
|
||||||
limitContext: true,
|
limitContext: false,
|
||||||
|
defaultLaunchModel: 'sonnet[1m]',
|
||||||
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
|
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
|
||||||
})
|
})
|
||||||
).toBe('sonnet');
|
).toBe('sonnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves limitContext requests and never manufactures 1M Sonnet or Haiku variants', () => {
|
||||||
|
expect(
|
||||||
|
resolveAnthropicLaunchModel({
|
||||||
|
selectedModel: 'sonnet',
|
||||||
|
limitContext: false,
|
||||||
|
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
|
||||||
|
})
|
||||||
|
).toBe('sonnet');
|
||||||
|
expect(
|
||||||
|
resolveAnthropicLaunchModel({
|
||||||
|
selectedModel: 'claude-sonnet-4-6',
|
||||||
|
limitContext: false,
|
||||||
|
availableLaunchModels: ['claude-sonnet-4-6', 'claude-sonnet-4-6[1m]'],
|
||||||
|
})
|
||||||
|
).toBe('claude-sonnet-4-6');
|
||||||
expect(
|
expect(
|
||||||
resolveAnthropicLaunchModel({
|
resolveAnthropicLaunchModel({
|
||||||
selectedModel: 'haiku',
|
selectedModel: 'haiku',
|
||||||
|
|
|
||||||
59
test/shared/utils/teamGraphDefaultLayout.test.ts
Normal file
59
test/shared/utils/teamGraphDefaultLayout.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
|
|
||||||
|
describe('team graph default layout', () => {
|
||||||
|
function members(count: number): Array<{ name: string; agentId: string }> {
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
name: `member-${index}`,
|
||||||
|
agentId: `agent-${index}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('seeds six visible owners into two row-orbit rows', () => {
|
||||||
|
const teamMembers = members(6);
|
||||||
|
|
||||||
|
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
|
||||||
|
'agent-0': { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
'agent-1': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
'agent-2': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
'agent-3': { ringIndex: 2, sectorIndex: 0 },
|
||||||
|
'agent-4': { ringIndex: 2, sectorIndex: 1 },
|
||||||
|
'agent-5': { ringIndex: 2, sectorIndex: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds eight visible owners into row-orbit defaults', () => {
|
||||||
|
const teamMembers = members(8);
|
||||||
|
|
||||||
|
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
|
||||||
|
'agent-0': { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
'agent-1': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
'agent-2': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
'agent-3': { ringIndex: 1, sectorIndex: 0 },
|
||||||
|
'agent-4': { ringIndex: 1, sectorIndex: 1 },
|
||||||
|
'agent-5': { ringIndex: 2, sectorIndex: 0 },
|
||||||
|
'agent-6': { ringIndex: 2, sectorIndex: 1 },
|
||||||
|
'agent-7': { ringIndex: 2, sectorIndex: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds twelve visible owners into four row-orbit rows', () => {
|
||||||
|
const teamMembers = members(12);
|
||||||
|
|
||||||
|
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
|
||||||
|
'agent-0': { ringIndex: 0, sectorIndex: 0 },
|
||||||
|
'agent-1': { ringIndex: 0, sectorIndex: 1 },
|
||||||
|
'agent-2': { ringIndex: 0, sectorIndex: 2 },
|
||||||
|
'agent-3': { ringIndex: 1, sectorIndex: 0 },
|
||||||
|
'agent-4': { ringIndex: 1, sectorIndex: 1 },
|
||||||
|
'agent-5': { ringIndex: 1, sectorIndex: 2 },
|
||||||
|
'agent-6': { ringIndex: 2, sectorIndex: 0 },
|
||||||
|
'agent-7': { ringIndex: 2, sectorIndex: 1 },
|
||||||
|
'agent-8': { ringIndex: 2, sectorIndex: 2 },
|
||||||
|
'agent-9': { ringIndex: 3, sectorIndex: 0 },
|
||||||
|
'agent-10': { ringIndex: 3, sectorIndex: 1 },
|
||||||
|
'agent-11': { ringIndex: 3, sectorIndex: 2 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,7 @@ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')) as
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: resolve(ROOT, 'src/renderer'),
|
root: resolve(ROOT, 'src/renderer'),
|
||||||
|
cacheDir: resolve(ROOT, 'node_modules/.vite/web-renderer'),
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue