Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion apps/server/src/orchestration/decider.settled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ function makeReadModel(
session: OrchestrationSession | null = null,
activities: OrchestrationThread["activities"] = [],
messages: OrchestrationThread["messages"] = [],
lifecycle: {
readonly pinnedAt?: string | null;
readonly snoozedUntil?: string | null;
readonly snoozedAt?: string | null;
} = {},
): OrchestrationReadModel {
return {
snapshotSequence: 0,
Expand All @@ -44,6 +49,9 @@ function makeReadModel(
archivedAt,
settledOverride,
settledAt: settledOverride === "settled" ? SETTLED_AT : null,
snoozedUntil: lifecycle.snoozedUntil ?? null,
snoozedAt: lifecycle.snoozedAt ?? (lifecycle.snoozedUntil != null ? SETTLED_AT : null),
pinnedAt: lifecycle.pinnedAt ?? null,
deletedAt: null,
messages,
proposedPlans: [],
Expand All @@ -69,7 +77,7 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi
}

it.layer(NodeServices.layer)("settled thread decider", (it) => {
it.effect("settles active threads and re-emits idempotently for settled ones", () =>
it.effect("settles awake threads without a redundant wake and re-emits idempotently", () =>
Effect.gen(function* () {
const event = yield* decideOrchestrationCommand({
command: {
Expand Down Expand Up @@ -108,6 +116,75 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
}),
);

it.effect("settling a snoozed thread also wakes it", () =>
Effect.gen(function* () {
const result = yield* decideOrchestrationCommand({
command: {
type: "thread.settle",
commandId: CommandId.make("cmd-settle-snoozed"),
threadId: ThreadId.make("thread-1"),
},
readModel: makeReadModel(null, null, null, [], [], {
snoozedUntil: "1970-01-02T09:00:00.000Z",
}),
});
const events = Array.isArray(result) ? result : [result];
expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unsnoozed"]);
const settled = events.find((entry) => entry.type === "thread.settled");
const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed");
if (settled?.type === "thread.settled" && unsnoozed?.type === "thread.unsnoozed") {
expect(unsnoozed.payload.reason).toBe("user");
expect(unsnoozed.payload.updatedAt).toBe(settled.payload.updatedAt);
}
}),
);

it.effect("repeated settle repairs legacy settled and snoozed state", () =>
Effect.gen(function* () {
const result = yield* decideOrchestrationCommand({
command: {
type: "thread.settle",
commandId: CommandId.make("cmd-settle-snoozed-again"),
threadId: ThreadId.make("thread-1"),
},
readModel: makeReadModel("settled", null, null, [], [], {
snoozedUntil: "1970-01-02T09:00:00.000Z",
}),
});
const events = Array.isArray(result) ? result : [result];
expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unsnoozed"]);
const settled = events.find((entry) => entry.type === "thread.settled");
const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed");
if (settled?.type === "thread.settled" && unsnoozed?.type === "thread.unsnoozed") {
expect(settled.payload.settledAt).toBe(SETTLED_AT);
expect(settled.payload.updatedAt).toBe(NOW);
expect(unsnoozed.payload.updatedAt).not.toBe(NOW);
}
}),
);

it.effect("settling a pinned and snoozed thread clears the pin and snooze", () =>
Effect.gen(function* () {
const result = yield* decideOrchestrationCommand({
command: {
type: "thread.settle",
commandId: CommandId.make("cmd-settle-pinned-snoozed"),
threadId: ThreadId.make("thread-1"),
},
readModel: makeReadModel(null, null, null, [], [], {
pinnedAt: SETTLED_AT,
snoozedUntil: "1970-01-02T09:00:00.000Z",
}),
});
const events = Array.isArray(result) ? result : [result];
expect(events.map((entry) => entry.type)).toEqual([
"thread.settled",
"thread.unpinned",
"thread.unsnoozed",
]);
}),
);

it.effect("rejects settling a thread with a live session", () =>
Effect.gen(function* () {
for (const status of ["starting", "running"] as const) {
Expand Down
36 changes: 24 additions & 12 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,16 +504,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
updatedAt: alreadySettled ? thread.updatedAt : occurredAt,
},
};
// Settling is "I'm done with this": it clears a pin the same way it
// parks the thread. Without this, settling a pinned thread would only
// stamp invisible state — the pin would hold the card in place until
// a separate unpin.
if (thread.pinnedAt == null) {
return settledEvent;
}
return [
settledEvent,
{
// Settling is "I'm done with this": clear states that would keep the
// row pinned or snoozed instead of showing the new settled state.
const companionEvents: Array<Omit<OrchestrationEvent, "sequence">> = [];
if (thread.pinnedAt != null) {
companionEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
Expand All @@ -525,8 +520,25 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
threadId: command.threadId,
updatedAt: occurredAt,
},
},
];
});
}
if (thread.snoozedUntil != null) {
companionEvents.push({
...(yield* withEventBase({
aggregateKind: "thread",
aggregateId: command.threadId,
occurredAt,
commandId: command.commandId,
})),
type: "thread.unsnoozed",
payload: {
threadId: command.threadId,
reason: "user",
updatedAt: occurredAt,
},
});
}
return companionEvents.length > 0 ? [settledEvent, ...companionEvents] : settledEvent;
}

case "thread.unsettle": {
Expand Down
Loading