perf(team): batch task activity interval resume across alive members
Collapses the per-member resume scan in getMemberSpawnStatuses into a single readdir + file lock + pass over the team's tasks. Avoids N x IO when multiple members become alive at launch. Semantics of the applied-set guard are preserved 1:1; the single-member API stays as a wrapper around the batch.
This commit is contained in:
parent
d9479b5c61
commit
9e3efa18ce
4 changed files with 628 additions and 33 deletions
|
|
@ -3815,6 +3815,36 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
at: string
|
||||
): void {
|
||||
if (memberNames.length === 0) return;
|
||||
const applied = this.getMemberTaskActivityResumeAppliedSet(teamName);
|
||||
const pendingNames: string[] = [];
|
||||
const pendingKeys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const name of memberNames) {
|
||||
const memberKey = this.normalizeMemberKeyForTaskActivity(name);
|
||||
if (!memberKey || applied.has(memberKey) || seen.has(memberKey)) continue;
|
||||
seen.add(memberKey);
|
||||
pendingNames.push(name);
|
||||
pendingKeys.push(memberKey);
|
||||
}
|
||||
if (pendingNames.length === 0) return;
|
||||
const result = this.taskActivityIntervalService.resumeActiveIntervalsForMembers(
|
||||
teamName,
|
||||
pendingNames,
|
||||
at
|
||||
);
|
||||
if (!result.failed) {
|
||||
for (const key of pendingKeys) {
|
||||
applied.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dropTaskActivityResumeMarkerForMember(teamName: string, memberName: string): void {
|
||||
const memberKey = this.normalizeMemberKeyForTaskActivity(memberName);
|
||||
if (!memberKey) return;
|
||||
|
|
@ -13983,13 +14013,19 @@ export class TeamProvisioningService {
|
|||
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
|
||||
});
|
||||
const runtimeObservedAt = nowIso();
|
||||
const aliveMemberNames: string[] = [];
|
||||
for (const [memberName, entry] of Object.entries(nextStatuses)) {
|
||||
if (entry.runtimeAlive === true) {
|
||||
this.resumeTaskActivityIntervalsForAliveMember(teamName, memberName, runtimeObservedAt);
|
||||
aliveMemberNames.push(memberName);
|
||||
} else {
|
||||
this.dropTaskActivityResumeMarkerForMember(teamName, memberName);
|
||||
}
|
||||
}
|
||||
this.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
aliveMemberNames,
|
||||
runtimeObservedAt
|
||||
);
|
||||
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
||||
const summary = expectedMembers
|
||||
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
|
||||
|
|
|
|||
|
|
@ -321,6 +321,58 @@ function writeTaskFile(filePath: string, task: MutableTeamTask): void {
|
|||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
function collectUniqueMemberKeys(memberNames: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const name of memberNames) {
|
||||
const key = normalizeMemberName(name);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function applyResumeIntervalsForMember(
|
||||
task: MutableTeamTask,
|
||||
memberKey: string,
|
||||
at: string
|
||||
): boolean {
|
||||
let changed = false;
|
||||
|
||||
if (
|
||||
task.status === 'in_progress' &&
|
||||
normalizeMemberName(task.owner) === memberKey &&
|
||||
!hasOpenWorkInterval(task)
|
||||
) {
|
||||
const activeStartedAt = getActiveWorkStartedAt(task);
|
||||
task.workIntervals = [
|
||||
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
|
||||
{ startedAt: resumeStartIso(activeStartedAt, at) },
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const activeReview = getActiveReviewStart(task);
|
||||
if (
|
||||
task.status === 'completed' &&
|
||||
activeReview &&
|
||||
normalizeMemberName(activeReview.reviewer) === memberKey &&
|
||||
!hasOpenReviewInterval(task, activeReview.reviewer)
|
||||
) {
|
||||
task.reviewIntervals = [
|
||||
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
|
||||
{
|
||||
reviewer: activeReview.reviewer,
|
||||
startedAt: resumeStartIso(activeReview.startedAt, at),
|
||||
},
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export class TeamTaskActivityIntervalService {
|
||||
private mutateTeamTasks(
|
||||
teamName: string,
|
||||
|
|
@ -403,42 +455,24 @@ export class TeamTaskActivityIntervalService {
|
|||
memberName: string,
|
||||
at = new Date().toISOString()
|
||||
): ActivityIntervalResult {
|
||||
const memberKey = normalizeMemberName(memberName);
|
||||
if (!memberKey) return { changedTasks: 0 };
|
||||
return this.resumeActiveIntervalsForMembers(teamName, [memberName], at);
|
||||
}
|
||||
|
||||
resumeActiveIntervalsForMembers(
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
at = new Date().toISOString()
|
||||
): ActivityIntervalResult {
|
||||
const memberKeys = collectUniqueMemberKeys(memberNames);
|
||||
if (memberKeys.length === 0) return { changedTasks: 0 };
|
||||
|
||||
return this.mutateTeamTasks(teamName, (task) => {
|
||||
let changed = false;
|
||||
|
||||
if (
|
||||
task.status === 'in_progress' &&
|
||||
normalizeMemberName(task.owner) === memberKey &&
|
||||
!hasOpenWorkInterval(task)
|
||||
) {
|
||||
const activeStartedAt = getActiveWorkStartedAt(task);
|
||||
task.workIntervals = [
|
||||
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
|
||||
{ startedAt: resumeStartIso(activeStartedAt, at) },
|
||||
];
|
||||
changed = true;
|
||||
for (const memberKey of memberKeys) {
|
||||
if (applyResumeIntervalsForMember(task, memberKey, at)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const activeReview = getActiveReviewStart(task);
|
||||
if (
|
||||
task.status === 'completed' &&
|
||||
activeReview &&
|
||||
normalizeMemberName(activeReview.reviewer) === memberKey &&
|
||||
!hasOpenReviewInterval(task, activeReview.reviewer)
|
||||
) {
|
||||
task.reviewIntervals = [
|
||||
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
|
||||
{
|
||||
reviewer: activeReview.reviewer,
|
||||
startedAt: resumeStartIso(activeReview.startedAt, at),
|
||||
},
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2755,6 +2755,193 @@ describe('TeamProvisioningService', () => {
|
|||
resumeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes alive-member batch through a single resumeActiveIntervalsForMembers call', () => {
|
||||
const batchSpy = vi
|
||||
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers')
|
||||
.mockReturnValue({ changedTasks: 0 });
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const internals = svc as unknown as {
|
||||
resumeTaskActivityIntervalsForAliveMembers: (
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
at: string
|
||||
) => void;
|
||||
};
|
||||
const teamName = 'alive-members-batch-team';
|
||||
|
||||
internals.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
['alice', 'bob', 'carol'],
|
||||
'2026-05-29T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
['alice', 'bob', 'carol'],
|
||||
'2026-05-29T00:00:00.000Z'
|
||||
);
|
||||
} finally {
|
||||
batchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips members that are already marked applied and dedupes name aliases', () => {
|
||||
const batchSpy = vi
|
||||
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers')
|
||||
.mockReturnValue({ changedTasks: 0 });
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const internals = svc as unknown as {
|
||||
resumeTaskActivityIntervalsForAliveMembers: (
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
at: string
|
||||
) => void;
|
||||
resumeTaskActivityIntervalsForAliveMember: (
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
at: string
|
||||
) => void;
|
||||
};
|
||||
const teamName = 'alive-members-applied-team';
|
||||
|
||||
// Prime the applied set for 'alice' via the single-member path.
|
||||
internals.resumeTaskActivityIntervalsForAliveMember(
|
||||
teamName,
|
||||
'alice',
|
||||
'2026-05-29T00:00:00.000Z'
|
||||
);
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
batchSpy.mockClear();
|
||||
|
||||
internals.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
['ALICE', 'alice', ' bob ', 'bob', 'carol'],
|
||||
'2026-05-29T00:00:10.000Z'
|
||||
);
|
||||
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
[' bob ', 'carol'],
|
||||
'2026-05-29T00:00:10.000Z'
|
||||
);
|
||||
} finally {
|
||||
batchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not invoke the batch when there is no pending alive member', () => {
|
||||
const batchSpy = vi
|
||||
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers')
|
||||
.mockReturnValue({ changedTasks: 0 });
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const internals = svc as unknown as {
|
||||
resumeTaskActivityIntervalsForAliveMembers: (
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
at: string
|
||||
) => void;
|
||||
};
|
||||
const teamName = 'alive-members-empty-team';
|
||||
|
||||
internals.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
[],
|
||||
'2026-05-29T00:00:00.000Z'
|
||||
);
|
||||
internals.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
['', ' '],
|
||||
'2026-05-29T00:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(batchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
batchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries the alive-member batch when the previous batch reported failure', () => {
|
||||
const batchSpy = vi
|
||||
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers')
|
||||
.mockReturnValueOnce({ changedTasks: 0, failed: true })
|
||||
.mockReturnValueOnce({ changedTasks: 2 });
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const internals = svc as unknown as {
|
||||
resumeTaskActivityIntervalsForAliveMembers: (
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
at: string
|
||||
) => void;
|
||||
};
|
||||
const teamName = 'alive-members-retry-team';
|
||||
|
||||
internals.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
['alice', 'bob'],
|
||||
'2026-05-29T00:00:00.000Z'
|
||||
);
|
||||
internals.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
['alice', 'bob'],
|
||||
'2026-05-29T00:00:05.000Z'
|
||||
);
|
||||
|
||||
expect(batchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(batchSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
teamName,
|
||||
['alice', 'bob'],
|
||||
'2026-05-29T00:00:00.000Z'
|
||||
);
|
||||
expect(batchSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
teamName,
|
||||
['alice', 'bob'],
|
||||
'2026-05-29T00:00:05.000Z'
|
||||
);
|
||||
} finally {
|
||||
batchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-invoke the batch on a subsequent call once members are applied', () => {
|
||||
const batchSpy = vi
|
||||
.spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMembers')
|
||||
.mockReturnValue({ changedTasks: 0 });
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const internals = svc as unknown as {
|
||||
resumeTaskActivityIntervalsForAliveMembers: (
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
at: string
|
||||
) => void;
|
||||
};
|
||||
const teamName = 'alive-members-dedup-team';
|
||||
|
||||
internals.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
['alice', 'bob'],
|
||||
'2026-05-29T00:00:00.000Z'
|
||||
);
|
||||
internals.resumeTaskActivityIntervalsForAliveMembers(
|
||||
teamName,
|
||||
['alice', 'bob'],
|
||||
'2026-05-29T00:00:05.000Z'
|
||||
);
|
||||
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
batchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('member spawn status launch reads', () => {
|
||||
|
|
|
|||
|
|
@ -811,4 +811,342 @@ describe('TeamTaskActivityIntervalService', () => {
|
|||
|
||||
expect(result).toEqual({ changedTasks: 0, failed: true });
|
||||
});
|
||||
|
||||
describe('resumeActiveIntervalsForMembers (batch)', () => {
|
||||
it('returns zero changes for an empty members list without scanning the tasks directory', () => {
|
||||
const service = new TeamTaskActivityIntervalService();
|
||||
const mutateSpy = vi.spyOn(
|
||||
service as unknown as {
|
||||
mutateTeamTasks: TeamTaskActivityIntervalService['resumeActiveIntervalsForMember'];
|
||||
},
|
||||
'mutateTeamTasks'
|
||||
);
|
||||
|
||||
const result = service.resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
[],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
|
||||
expect(result).toEqual({ changedTasks: 0 });
|
||||
expect(mutateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns zero changes when all member names are blank', () => {
|
||||
const service = new TeamTaskActivityIntervalService();
|
||||
const mutateSpy = vi.spyOn(
|
||||
service as unknown as {
|
||||
mutateTeamTasks: TeamTaskActivityIntervalService['resumeActiveIntervalsForMember'];
|
||||
},
|
||||
'mutateTeamTasks'
|
||||
);
|
||||
|
||||
const result = service.resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
['', ' ', null as unknown as string],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
|
||||
expect(result).toEqual({ changedTasks: 0 });
|
||||
expect(mutateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('produces the same per-task result as sequential single-member calls', async () => {
|
||||
const baseTime = '2026-05-08T10:00:00.000Z';
|
||||
const resumeAt = '2026-05-08T10:20:00.000Z';
|
||||
|
||||
async function seed(teamName: string): Promise<void> {
|
||||
await writeTask(teamName, {
|
||||
id: 'work-bob',
|
||||
subject: 'Bob work',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: baseTime, completedAt: '2026-05-08T10:05:00.000Z' }],
|
||||
historyEvents: [],
|
||||
});
|
||||
await writeTask(teamName, {
|
||||
id: 'work-tom',
|
||||
subject: 'Tom work',
|
||||
owner: 'tom',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: baseTime, completedAt: '2026-05-08T10:05:00.000Z' }],
|
||||
historyEvents: [],
|
||||
});
|
||||
await writeTask(teamName, {
|
||||
id: 'review-task',
|
||||
subject: 'Review',
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await seed('seq');
|
||||
const seqService = new TeamTaskActivityIntervalService();
|
||||
seqService.resumeActiveIntervalsForMember('seq', 'bob', resumeAt);
|
||||
seqService.resumeActiveIntervalsForMember('seq', 'tom', resumeAt);
|
||||
|
||||
await seed('batch');
|
||||
const batchService = new TeamTaskActivityIntervalService();
|
||||
const batchResult = batchService.resumeActiveIntervalsForMembers(
|
||||
'batch',
|
||||
['bob', 'tom'],
|
||||
resumeAt
|
||||
);
|
||||
|
||||
expect(batchResult.changedTasks).toBe(3);
|
||||
for (const id of ['work-bob', 'work-tom', 'review-task']) {
|
||||
const seqTask = await readTask('seq', id);
|
||||
const batchTask = await readTask('batch', id);
|
||||
expect(batchTask.workIntervals).toEqual(seqTask.workIntervals);
|
||||
expect(batchTask.reviewIntervals).toEqual(seqTask.reviewIntervals);
|
||||
expect(batchTask.status).toBe(seqTask.status);
|
||||
}
|
||||
});
|
||||
|
||||
it('acquires the team lock once regardless of the number of members', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'task-bob',
|
||||
subject: 'Bob',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [],
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'event-work-started-bob',
|
||||
type: 'status_changed',
|
||||
from: 'pending',
|
||||
to: 'in_progress',
|
||||
timestamp: '2026-05-08T10:00:00.000Z',
|
||||
actor: 'bob',
|
||||
},
|
||||
],
|
||||
});
|
||||
await writeTask('alpha', {
|
||||
id: 'task-tom',
|
||||
subject: 'Tom',
|
||||
owner: 'tom',
|
||||
status: 'in_progress',
|
||||
workIntervals: [],
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'event-work-started-tom',
|
||||
type: 'status_changed',
|
||||
from: 'pending',
|
||||
to: 'in_progress',
|
||||
timestamp: '2026-05-08T10:01:00.000Z',
|
||||
actor: 'tom',
|
||||
},
|
||||
],
|
||||
});
|
||||
await writeTask('alpha', {
|
||||
id: 'task-zoe',
|
||||
subject: 'Zoe',
|
||||
owner: 'zoe',
|
||||
status: 'in_progress',
|
||||
workIntervals: [],
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'event-work-started-zoe',
|
||||
type: 'status_changed',
|
||||
from: 'pending',
|
||||
to: 'in_progress',
|
||||
timestamp: '2026-05-08T10:02:00.000Z',
|
||||
actor: 'zoe',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const service = new TeamTaskActivityIntervalService();
|
||||
const mutateSpy = vi.spyOn(
|
||||
service as unknown as {
|
||||
mutateTeamTasks: TeamTaskActivityIntervalService['resumeActiveIntervalsForMember'];
|
||||
},
|
||||
'mutateTeamTasks'
|
||||
);
|
||||
|
||||
const result = service.resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
['bob', 'tom', 'zoe'],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
|
||||
expect(mutateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.changedTasks).toBe(3);
|
||||
});
|
||||
|
||||
it('deduplicates member names that normalize to the same key', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'work-task',
|
||||
subject: 'Build',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||
],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
['bob', 'BOB', ' bob '],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
const task = await readTask('alpha', 'work-task');
|
||||
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not write task files when no listed member matches any task', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'work-bob',
|
||||
subject: 'Bob work',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const taskPath = path.join(tempDir, 'tasks', 'alpha', 'work-bob.json');
|
||||
const beforeMtime = (await fs.stat(taskPath)).mtimeMs;
|
||||
const beforeContents = await fs.readFile(taskPath, 'utf8');
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
['no-such-member', 'another-ghost'],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
|
||||
const afterMtime = (await fs.stat(taskPath)).mtimeMs;
|
||||
const afterContents = await fs.readFile(taskPath, 'utf8');
|
||||
|
||||
expect(result.changedTasks).toBe(0);
|
||||
expect(afterMtime).toBe(beforeMtime);
|
||||
expect(afterContents).toBe(beforeContents);
|
||||
});
|
||||
|
||||
it('opens at most one work interval per task even when the owner is listed twice', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'work-task',
|
||||
subject: 'Build',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||
],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
['bob', 'bob'],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
const task = await readTask('alpha', 'work-task');
|
||||
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 0 changes for a non-existent team directory', async () => {
|
||||
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers(
|
||||
'ghost-team',
|
||||
['bob'],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
|
||||
expect(result).toEqual({ changedTasks: 0 });
|
||||
});
|
||||
|
||||
it('reports failure when task directory cannot be scanned', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'tasks'), { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, 'tasks', 'alpha'), 'not a directory', 'utf8');
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
['bob'],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
|
||||
expect(result).toEqual({ changedTasks: 0, failed: true });
|
||||
});
|
||||
|
||||
it('skips malformed task JSON files and still applies updates to valid ones', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'good-task',
|
||||
subject: 'Build',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||
],
|
||||
historyEvents: [],
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'tasks', 'alpha', 'broken-task.json'),
|
||||
'{ this is not valid json',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMembers(
|
||||
'alpha',
|
||||
['bob'],
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
const goodTask = await readTask('alpha', 'good-task');
|
||||
|
||||
expect(result.changedTasks).toBe(1);
|
||||
expect(goodTask.workIntervals).toEqual([
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||
{ startedAt: '2026-05-08T10:20:00.000Z' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes single-member resumeActiveIntervalsForMember through the batch implementation', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'work-task',
|
||||
subject: 'Build',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||
],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const service = new TeamTaskActivityIntervalService();
|
||||
const batchSpy = vi.spyOn(service, 'resumeActiveIntervalsForMembers');
|
||||
|
||||
const result = service.resumeActiveIntervalsForMember(
|
||||
'alpha',
|
||||
'bob',
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
|
||||
expect(batchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(batchSpy).toHaveBeenCalledWith('alpha', ['bob'], '2026-05-08T10:20:00.000Z');
|
||||
expect(result.changedTasks).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue