Domain primitives: versioned WorkDefinition/DesignPacket with risk, questions, and approval gates; lifecycle transition table with invalidation rules; Resolver (Run/Attempt/outcomes/CodingKitV0); provider-neutral HarnessRuntime with normalized events and a deterministic FakeHarnessLive that always reaches a terminal classification and never claims implementation. Convex durable model: workDefinitions, workQuestions, workApprovals, designPackets, workSlices, workRuns, workAttempts, workAttemptEvents tables; broadened works.status union; generalized workEvents with optional signalId, referenceId, and payloadJson. Authenticated commands for definition request/ save/revise/approve, question answer/withdraw, design save/revise/approve, and simulated execution start/cancel/retry with leased attempts, checkpointed events, and expired-lease reconciliation. Private work-planner FLUE agent with proposal-only tools (definition, design, question). Convex validates and stores every proposal; FLUE never approves or advances Work. Expanded Work card with Outcome, Design, and Build sections wired to the new mutations. Slice 1 conversation and provenance preserved.
250 lines
6.5 KiB
TypeScript
250 lines
6.5 KiB
TypeScript
import { Array as EffectArray, Effect, Order, Schema } from "effect";
|
|
|
|
export { WorkStatus } from "./work-lifecycle";
|
|
export type { WorkStatus as WorkLifecycleStatus } from "./work-lifecycle";
|
|
|
|
const MeaningfulString = Schema.String.check(
|
|
Schema.makeFilter((value) => value.trim().length > 0, {
|
|
expected: "a non-empty string",
|
|
})
|
|
);
|
|
|
|
export const WorkDraft = Schema.Struct({
|
|
objective: MeaningfulString,
|
|
title: MeaningfulString,
|
|
});
|
|
export type WorkDraft = typeof WorkDraft.Type;
|
|
|
|
export const SignalProblem = Schema.Struct({
|
|
desiredOutcome: MeaningfulString,
|
|
summary: MeaningfulString,
|
|
title: MeaningfulString,
|
|
});
|
|
|
|
export const WorkCommandReason = Schema.Literals([
|
|
"InvalidInput",
|
|
"ProjectMismatch",
|
|
"TitleTooLong",
|
|
"ObjectiveTooLong",
|
|
]);
|
|
export type WorkCommandReason = typeof WorkCommandReason.Type;
|
|
|
|
export class WorkCommandError extends Schema.TaggedErrorClass<WorkCommandError>()(
|
|
"WorkCommandError",
|
|
{
|
|
message: Schema.String,
|
|
reason: WorkCommandReason,
|
|
}
|
|
) {}
|
|
|
|
export const workDraftFromSignal = Effect.fn("Work.draftFromSignal")(
|
|
function* workDraftFromSignal(input: unknown) {
|
|
const problem = yield* Schema.decodeUnknownEffect(SignalProblem)(
|
|
input
|
|
).pipe(
|
|
Effect.mapError(
|
|
() =>
|
|
new WorkCommandError({
|
|
message: "Signal is missing a valid problem statement",
|
|
reason: "InvalidInput",
|
|
})
|
|
)
|
|
);
|
|
const title = problem.title.trim();
|
|
const objective = problem.desiredOutcome.trim();
|
|
|
|
if (title.length > 160) {
|
|
return yield* new WorkCommandError({
|
|
message: "Work title must be 160 characters or fewer",
|
|
reason: "TitleTooLong",
|
|
});
|
|
}
|
|
if (objective.length > 2000) {
|
|
return yield* new WorkCommandError({
|
|
message: "Work objective must be 2000 characters or fewer",
|
|
reason: "ObjectiveTooLong",
|
|
});
|
|
}
|
|
|
|
return { objective, title } satisfies WorkDraft;
|
|
}
|
|
);
|
|
|
|
const WorkIdentity = Schema.Struct({
|
|
organizationId: MeaningfulString,
|
|
projectId: MeaningfulString,
|
|
workId: MeaningfulString,
|
|
});
|
|
|
|
const SignalIdentity = Schema.Struct({
|
|
organizationId: MeaningfulString,
|
|
projectId: MeaningfulString,
|
|
signalId: MeaningfulString,
|
|
title: MeaningfulString,
|
|
});
|
|
|
|
const WorkAttachmentInput = Schema.Struct({
|
|
signal: SignalIdentity,
|
|
work: WorkIdentity,
|
|
});
|
|
|
|
export const WorkEventKind = Schema.Literals([
|
|
"work.proposed",
|
|
"signal.attached",
|
|
"definition.requested",
|
|
"definition.saved",
|
|
"definition.revised",
|
|
"definition.approved",
|
|
"definition.invalidated",
|
|
"question.answered",
|
|
"question.withdrawn",
|
|
"design.requested",
|
|
"design.saved",
|
|
"design.revised",
|
|
"design.approved",
|
|
"design.invalidated",
|
|
"run.started",
|
|
"run.cancelled",
|
|
"attempt.claimed",
|
|
"attempt.event",
|
|
"attempt.completed",
|
|
"attempt.reconciled",
|
|
]);
|
|
export type WorkEventKind = typeof WorkEventKind.Type;
|
|
|
|
export const WorkEventDraft = Schema.Struct({
|
|
data: Schema.Struct({
|
|
signalId: MeaningfulString,
|
|
title: MeaningfulString,
|
|
}),
|
|
idempotencyKey: MeaningfulString,
|
|
kind: WorkEventKind,
|
|
});
|
|
export type WorkEventDraft = typeof WorkEventDraft.Type;
|
|
|
|
export const workProposedEventFromSignal = Effect.fn(
|
|
"Work.proposedEventFromSignal"
|
|
)(function* workProposedEventFromSignal(input: unknown) {
|
|
const signal = yield* Schema.decodeUnknownEffect(SignalIdentity)(input).pipe(
|
|
Effect.mapError(
|
|
() =>
|
|
new WorkCommandError({
|
|
message: "Signal identity is invalid",
|
|
reason: "InvalidInput",
|
|
})
|
|
)
|
|
);
|
|
return {
|
|
data: { signalId: signal.signalId, title: signal.title.trim() },
|
|
idempotencyKey: `proposed:${signal.signalId}`,
|
|
kind: "work.proposed",
|
|
} satisfies WorkEventDraft;
|
|
});
|
|
|
|
export const signalAttachedEvent = Effect.fn("Work.signalAttachedEvent")(
|
|
function* signalAttachedEvent(input: unknown) {
|
|
const attachment = yield* Schema.decodeUnknownEffect(WorkAttachmentInput)(
|
|
input
|
|
).pipe(
|
|
Effect.mapError(
|
|
() =>
|
|
new WorkCommandError({
|
|
message: "Work attachment input is invalid",
|
|
reason: "InvalidInput",
|
|
})
|
|
)
|
|
);
|
|
|
|
if (
|
|
attachment.signal.organizationId !== attachment.work.organizationId ||
|
|
attachment.signal.projectId !== attachment.work.projectId
|
|
) {
|
|
return yield* new WorkCommandError({
|
|
message: "Signal and Work must belong to the same project",
|
|
reason: "ProjectMismatch",
|
|
});
|
|
}
|
|
|
|
return {
|
|
data: {
|
|
signalId: attachment.signal.signalId,
|
|
title: attachment.signal.title.trim(),
|
|
},
|
|
idempotencyKey: `signal:${attachment.signal.signalId}`,
|
|
kind: "signal.attached",
|
|
} satisfies WorkEventDraft;
|
|
}
|
|
);
|
|
|
|
const WorkNoticeInput = Schema.Array(
|
|
Schema.Struct({
|
|
_id: MeaningfulString,
|
|
events: Schema.Array(
|
|
Schema.Struct({
|
|
_id: MeaningfulString,
|
|
createdAt: Schema.Number,
|
|
kind: WorkEventKind,
|
|
})
|
|
),
|
|
signals: Schema.Array(
|
|
Schema.Struct({
|
|
sources: Schema.Array(
|
|
Schema.Struct({ messageId: MeaningfulString, rawText: Schema.String })
|
|
),
|
|
})
|
|
),
|
|
})
|
|
);
|
|
|
|
export interface WorkNotice {
|
|
readonly createdAt: number;
|
|
readonly eventId: string;
|
|
readonly sourceMessageIds: readonly string[];
|
|
readonly sourceTexts: readonly string[];
|
|
readonly workId: string;
|
|
}
|
|
|
|
const projectWorkNoticesEffect = Effect.fn("Work.projectNotices")(
|
|
function* projectWorkNoticesEffect(input: unknown) {
|
|
const works = yield* Schema.decodeUnknownEffect(WorkNoticeInput)(
|
|
input
|
|
).pipe(
|
|
Effect.mapError(
|
|
() =>
|
|
new WorkCommandError({
|
|
message: "Work notice data is invalid",
|
|
reason: "InvalidInput",
|
|
})
|
|
)
|
|
);
|
|
|
|
const notices = works.flatMap((work) => {
|
|
const sourceMessageIds = work.signals.flatMap((signal) =>
|
|
signal.sources.map((source) => source.messageId)
|
|
);
|
|
const sourceTexts = work.signals.flatMap((signal) =>
|
|
signal.sources.map((source) => source.rawText)
|
|
);
|
|
return work.events
|
|
.filter((event) => event.kind === "work.proposed")
|
|
.map(
|
|
(event): WorkNotice => ({
|
|
createdAt: event.createdAt,
|
|
eventId: event._id,
|
|
sourceMessageIds,
|
|
sourceTexts,
|
|
workId: work._id,
|
|
})
|
|
);
|
|
});
|
|
return EffectArray.sortWith(
|
|
notices,
|
|
(notice) => notice.createdAt,
|
|
Order.Number
|
|
);
|
|
}
|
|
);
|
|
|
|
export const projectWorkNotices = (input: unknown): readonly WorkNotice[] =>
|
|
Effect.runSync(projectWorkNoticesEffect(input));
|