Skip to content
Merged
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
27 changes: 19 additions & 8 deletions apps/web/e2e/channel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,7 +1319,8 @@ test("channel environment surface shows read-only context and opens review", asy
};
});

await page.getByLabel("Workspace task toolbar").getByRole("button", { name: "Summary" }).click();
const summaryButton = page.getByLabel("Workspace task toolbar").getByRole("button", { name: "Summary" });
await summaryButton.click();
const popover = page.locator(".channel-progress-popover");
await expect(popover).toBeVisible();
await expect.poll(async () => Math.round((await popover.boundingBox())?.width ?? 0)).toBeGreaterThanOrEqual(270);
Expand All @@ -1341,7 +1342,7 @@ test("channel environment surface shows read-only context and opens review", asy
await expect(quickEnvironmentPage).toContainText("ling/channel-environment");
await page.getByRole("button", { name: "Back to channel" }).click();
await expect(quickEnvironmentPage).toHaveCount(0);
await page.getByLabel("Workspace task toolbar").getByRole("button", { name: "Summary" }).click();
await summaryButton.click();
await expect(popover).toBeVisible();

const dock = page.locator(".channel-side-panel");
Expand Down Expand Up @@ -1498,11 +1499,14 @@ test("channel environment surface shows read-only context and opens review", asy
await page.mouse.move(860, expandedResizeHandleBox!.y + 40, { steps: 12 });
await page.mouse.up();
await expect.poll(async () => Math.round((await dock.boundingBox())?.width ?? 0)).toBeGreaterThanOrEqual(840);
await expect(popover).toBeVisible();
await expect(popover).toBeHidden();
await expect(summaryButton).toHaveAttribute("aria-pressed", "true");
await expect(summaryButton).toHaveAttribute("aria-expanded", "false");
await expect.poll(async () => Math.round((await page.locator(".activity-shell .channel-composer").boundingBox())?.width ?? 0)).toBeGreaterThanOrEqual(560);
await page.locator(".task-toolbar-panel").click();
await expect(dock).toHaveCount(0);
await expect(popover).toBeVisible();
await expect(summaryButton).toHaveAttribute("aria-expanded", "true");

await page.getByRole("button", { name: "More channel views" }).click();
await page.getByRole("menuitem", { name: "Environment" }).click();
Expand Down Expand Up @@ -2009,9 +2013,10 @@ test("wide channel progress popover does not resize the conversation", async ({
expect(spacing.gap).toBeGreaterThanOrEqual(48);
expect(spacing.overlapX).toBe(0);

await page.locator(".task-toolbar-progress").click();
await expect(page.locator(".channel-progress-popover")).toHaveClass(/is-popover-collapsed/);
const summaryButton = page.locator(".task-toolbar-progress");
await summaryButton.click();
await expect(page.locator(".channel-progress-popover")).toHaveCount(0);
await expect(summaryButton).not.toHaveAttribute("aria-pressed", "true");
});

test("channel runtime policy is visible while preferred specialist controls stay hidden", async ({ page }) => {
Expand Down Expand Up @@ -7344,7 +7349,8 @@ test("progress popover owns workflow status while the right drawer avoids duplic
await expect(qaSpan).toContainText("Waiting for approval");
await expect(qaSpan.locator(".conversation-work-cell-agent-span-detail")).toContainText("Waiting for approval: Validate progress panel");

await page.locator(".task-toolbar-progress").click();
const summaryButton = page.locator(".task-toolbar-progress");
await summaryButton.click();
const progressPopover = page.locator(".channel-progress-popover");
await expect(progressPopover).toHaveCount(1);
await expect(progressPopover).toContainText("Ship workflow UI");
Expand All @@ -7357,7 +7363,9 @@ test("progress popover owns workflow status while the right drawer avoids duplic
await expect(progressPopover).not.toContainText("branch_engineer");

await page.locator(".task-toolbar-panel").click();
await expect(progressPopover).toBeVisible();
await expect(progressPopover).toBeHidden();
await expect(summaryButton).toHaveAttribute("aria-pressed", "true");
await expect(summaryButton).toHaveAttribute("aria-expanded", "false");
const panel = page.locator(".channel-side-panel.right-dock");
await expect(panel).toHaveAttribute("aria-label", "Channel workspace dock");
await expect(panel).toContainText("Workspace tools");
Expand Down Expand Up @@ -11749,7 +11757,10 @@ test("channel activity derives task run approval transition and workflow events"
await page.locator(".task-toolbar-progress").click();
await expect(page.locator(".channel-progress-popover")).toHaveCount(1);
await page.getByRole("button", { name: "Show details" }).click();
await expect(page.locator(".channel-progress-popover")).toBeVisible();
const summaryButton = page.locator(".task-toolbar-progress");
await expect(page.locator(".channel-progress-popover")).toBeHidden();
await expect(summaryButton).toHaveAttribute("aria-pressed", "true");
await expect(summaryButton).toHaveAttribute("aria-expanded", "false");
const contextPanel = page.locator(".channel-side-panel.right-dock");
await expect(contextPanel).toHaveAttribute("aria-label", "Channel workspace dock");
await expect(contextPanel.getByLabel("Attention counts")).toHaveCount(0);
Expand Down
9 changes: 9 additions & 0 deletions apps/web/e2e/ui2-sidebar-rail-sections.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ test("Sidebar dark compact launchers expose one portal tooltip without object ac
await expect(identity.locator).toBeFocused();
}
await expectPortalTooltip(page, identity.tooltip);
const tooltip = page.getByRole("tooltip");
await expect(tooltip).toHaveAttribute("data-side", "right");
const [launcherBox, tooltipBox] = await Promise.all([
identity.locator.boundingBox(),
tooltip.boundingBox(),
]);
expect(launcherBox).not.toBeNull();
expect(tooltipBox).not.toBeNull();
expect(tooltipBox!.x).toBeGreaterThan(launcherBox!.x + launcherBox!.width);
await captureEvidence(page, `1280x800-ling-dark-launcher-${index + 1}-tooltip.png`);
}

Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/features/navigation/SidebarSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
StatusIndicator,
} from "../../shared/ui/index.js";
import { SidebarSectionMenu, type SidebarSectionMenuItem } from "./SidebarSectionMenu.js";
import { SidebarTooltip } from "./SidebarTooltip.js";

export type SidebarSectionId = "channels" | "teammates" | "recent";

Expand Down Expand Up @@ -61,14 +62,17 @@ export function SidebarSection({
return (
<section className={sectionClassName} data-sidebar-section={sectionId}>
<span className="sidebar-rail-launcher-shell">
<IconButton
className="sidebar-rail-launcher"
data-sidebar-rail-launcher={sectionId}
icon={railIcon}
label={title}
onClick={() => onRailActivate(sectionId)}
pressed={railActive}
/>
<SidebarTooltip label={title}>
<IconButton
className="sidebar-rail-launcher"
data-sidebar-rail-launcher={sectionId}
icon={railIcon}
label={title}
onClick={() => onRailActivate(sectionId)}
pressed={railActive}
tooltip=""
/>
</SidebarTooltip>
{stale ? (
<StatusIndicator
aria-label={`${title}: ${staleLabel}`}
Expand Down
190 changes: 188 additions & 2 deletions packages/core/src/storage/sqlite/channel-workflow-store.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import type { SQLOutputValue } from "node:sqlite";
import {
createWorkflowParticipantAuthorityV1,
delegatedStepFinalizationBasisFingerprintV1,
isConversationContextProjectionMetadata,
isDelegatedOutcomeV1,
isDelegatedStepRequirementV1,
isWorkflowCompletionGateV1,
isWorkflowParticipantAuthorityV1,
isWorkflowParticipantProviderBindingV1,
workflowGraphFingerprintV1,
} from "@ling-stack/contracts";
import type {
DelegatedEvidenceRefV1,
DelegatedOutcomeV1,
WorkflowCompletionGateV1,
} from "@ling-stack/contracts";
import type { RunEventEnvelope } from "../../run-types.js";
import {
buildWorkflowCompletionExpectedReadSet,
workflowCompletionGateBasisFingerprintV1,
Expand Down Expand Up @@ -72,6 +76,8 @@ import {
} from "./select-sql.js";
import type { SqliteStoreContext } from "./store-context.js";

type FinalizableDelegatedOutcomeV1 = Exclude<DelegatedOutcomeV1, { readonly kind: "workflow_started" }>;

export interface SqliteChannelWorkflowStorePorts {
readonly assertChannelExists: (channelId: string) => void;
readonly assertHandoffInChannel: (channelId: string, handoffId: string) => void;
Expand Down Expand Up @@ -826,6 +832,10 @@ export class SqliteChannelWorkflowStore {
if (!step || step.status !== input.expectedStatus || step.updatedAt !== input.expectedUpdatedAt || step.runId !== expectedRunId) {
return { ok: false, code: "stale_transition", currentStatus: step?.status ?? input.expectedStatus };
}
const workflow = this.getChannelWorkflow(step.workflowId);
if (!workflow) {
return { ok: false, code: "invalid_transition", currentStatus: step.status };
}
if (input.delegatedOutcome.workflowId !== step.workflowId
|| input.delegatedOutcome.workflowStepId !== step.id
|| input.delegatedOutcome.participantId !== step.toParticipantId
Expand Down Expand Up @@ -866,6 +876,8 @@ export class SqliteChannelWorkflowStore {
this.context.database.exec("BEGIN IMMEDIATE;");
try {
let expectedArtifactIds: readonly string[] = [];
let actualRunEventCount = 0;
let actualRunLatestSeq = 0;
if (input.expectedRun.kind === "linked_run") {
const run = this.context.database.prepare(
"SELECT status, updated_at, work_unit_id FROM run_sessions WHERE id = ?",
Expand All @@ -886,8 +898,10 @@ export class SqliteChannelWorkflowStore {
SELECT COUNT(*) AS count, COALESCE(MAX(seq), 0) AS latest_seq
FROM run_events WHERE run_id = ?
`).get(input.expectedRun.id) as Record<string, SQLOutputValue>;
if (Number(eventWatermark.count) !== input.expectedRun.eventWatermark.count
|| Number(eventWatermark.latest_seq) !== input.expectedRun.eventWatermark.latestSeq) {
actualRunEventCount = Number(eventWatermark.count);
actualRunLatestSeq = Number(eventWatermark.latest_seq);
if (actualRunEventCount !== input.expectedRun.eventWatermark.count
|| actualRunLatestSeq !== input.expectedRun.eventWatermark.latestSeq) {
this.context.database.exec("ROLLBACK;");
return { ok: false, code: "stale_transition", currentStatus: step.status };
}
Expand All @@ -903,6 +917,21 @@ export class SqliteChannelWorkflowStore {
this.context.database.exec("ROLLBACK;");
return { ok: false, code: "invalid_transition", currentStatus: step.status };
}
if (input.outputArtifactId && !expectedArtifactIds.includes(input.outputArtifactId)) {
this.context.database.exec("ROLLBACK;");
return { ok: false, code: "invalid_transition", currentStatus: step.status };
}
if (!this.delegatedOutcomeEvidenceIsCurrent(
input.delegatedOutcome as FinalizableDelegatedOutcomeV1,
workflow,
step,
expectedRunId,
input.delegatedOutcome.lineage.attemptId,
expectedArtifactIds,
)) {
this.context.database.exec("ROLLBACK;");
return { ok: false, code: "invalid_transition", currentStatus: step.status };
}
if (input.expectedApproval) {
const approval = this.context.database.prepare(
"SELECT status, resolved_at FROM approval_requests WHERE id = ?",
Expand All @@ -913,6 +942,27 @@ export class SqliteChannelWorkflowStore {
return { ok: false, code: "stale_transition", currentStatus: step.status };
}
}
const canonicalBasisFingerprint = delegatedStepFinalizationBasisFingerprintV1({
workflowId: workflow.id,
workflowStepId: step.id,
stepUpdatedAt: input.expectedUpdatedAt,
...(input.expectedRun.kind === "linked_run" ? {
runId: input.expectedRun.id,
runStatus: input.expectedRun.status,
runUpdatedAt: input.expectedRun.updatedAt,
} : {}),
runEventCount: actualRunEventCount,
runLatestSeq: actualRunLatestSeq,
requirementFingerprint: step.metadata?.delegatedRequirement?.fingerprint,
approval: input.expectedApproval,
artifactIds: expectedArtifactIds,
outcome: input.delegatedOutcome,
verification: input.executionVerification,
});
if (canonicalBasisFingerprint !== input.delegatedOutcomeBasisFingerprint) {
this.context.database.exec("ROLLBACK;");
return { ok: false, code: "invalid_transition", currentStatus: step.status };
}
const latest = this.context.database.prepare(
"SELECT status, run_id, updated_at, metadata_json FROM channel_workflow_steps WHERE id = ?",
).get(step.id) as Record<string, SQLOutputValue> | undefined;
Expand Down Expand Up @@ -1008,6 +1058,126 @@ export class SqliteChannelWorkflowStore {
}
}

private delegatedOutcomeEvidenceIsCurrent(
outcome: FinalizableDelegatedOutcomeV1,
workflow: ChannelWorkflow,
step: ChannelWorkflowStep,
expectedRunId: string | undefined,
expectedAttemptId: string | null,
expectedArtifactIds: readonly string[],
): boolean {
if (outcome.kind === "completed") {
const requirement = step.metadata?.delegatedRequirement;
const manifest = workflow.metadata?.requiredParticipantManifest;
if (!isDelegatedStepRequirementV1(requirement)
|| !manifest
|| requirement.workflowId !== workflow.id
|| requirement.workflowStepId !== step.id
|| requirement.participantId !== step.toParticipantId
|| requirement.participantManifestFingerprint !== manifest.fingerprint
|| outcome.evidenceRefs.some((ref) => !requirement.acceptedEvidenceKinds.includes(ref.kind))) {
return false;
}
}
const expectedArtifacts = new Set(expectedArtifactIds);
for (const sourceRef of outcome.lineage.sourceRefs) {
if (sourceRef.type === "workflow" && sourceRef.id !== workflow.id) return false;
if (sourceRef.type === "workflow_step" && sourceRef.id !== step.id) return false;
if (sourceRef.type === "run" && sourceRef.id !== (expectedRunId ?? "")) return false;
if (sourceRef.type === "attempt" && sourceRef.id !== (expectedAttemptId ?? "")) return false;
if (sourceRef.type === "artifact") {
if (!expectedRunId || !expectedArtifacts.has(sourceRef.id)) return false;
const artifact = this.ports.getArtifact(sourceRef.id);
if (!artifact || artifact.runId !== expectedRunId) return false;
}
if (sourceRef.type === "execution_action"
&& (!expectedRunId || !this.executionActionEvidenceIsCurrent(expectedRunId, expectedAttemptId, sourceRef.id))) {
return false;
}
if (sourceRef.type === "approval") {
const approval = this.findApprovalForExpectedRun(expectedRunId, sourceRef.id);
if (!approval || (expectedRunId ? approval.runId !== expectedRunId : Boolean(approval.runId))) return false;
}
}
for (const evidenceRef of outcome.evidenceRefs) {
if (evidenceRef.kind === "conversation_item") continue;
if (evidenceRef.kind === "workflow_output_packet" || evidenceRef.kind === "artifact") {
if (!expectedRunId || !expectedArtifacts.has(evidenceRef.id)) return false;
const artifact = this.ports.getArtifact(evidenceRef.id);
if (!artifact || artifact.runId !== expectedRunId) return false;
continue;
}
if (!expectedRunId || !this.executionActionEvidenceIsCurrent(expectedRunId, expectedAttemptId, evidenceRef.id)) {
return false;
}
}
return true;
}

private executionActionEvidenceIsCurrent(
runId: string,
attemptId: string | null,
actionId: string,
): boolean {
if (actionId.startsWith("approval:")) {
const approvalId = actionId.slice("approval:".length);
if (!approvalId) return false;
const approval = this.findApprovalForExpectedRun(runId, approvalId);
if (!approval || approval.status !== "approved" || !approval.toolCallId) return false;
return this.toolExecutionEvidenceIsCurrent(runId, attemptId, approval.toolCallId);
}
if (!actionId.startsWith("tool:")) return false;
const remainder = actionId.slice("tool:".length);
const separator = remainder.indexOf(":");
const actionRunId = separator > 0 ? remainder.slice(0, separator) : "";
const toolCallId = separator > 0 ? remainder.slice(separator + 1) : "";
if (actionRunId !== runId || !toolCallId) return false;
return this.toolExecutionEvidenceIsCurrent(runId, attemptId, toolCallId);
}

private toolExecutionEvidenceIsCurrent(
runId: string,
attemptId: string | null,
toolCallId: string,
): boolean {
const events = this.listAllRunEvents(runId);
let latestToolEvent: RunEventEnvelope | undefined;
let terminalToolEvent: RunEventEnvelope | undefined;
let explicitAttemptId: string | undefined;
for (const event of events) {
if (event.type !== "tool_start" && event.type !== "tool_update" && event.type !== "tool_end") continue;
if (!isUnknownRecord(event.data) || event.data.toolCallId !== toolCallId) continue;
latestToolEvent = event;
if (event.type === "tool_end") terminalToolEvent = event;
if (typeof event.data.attemptId === "string" && event.data.attemptId.trim()) {
explicitAttemptId = event.data.attemptId;
}
}
if (!latestToolEvent || !terminalToolEvent || !isUnknownRecord(terminalToolEvent.data)
|| terminalToolEvent.data.isError === true) return false;
const actionAttemptId = explicitAttemptId ?? latestRunAttemptIdAtOrBefore(events, latestToolEvent.seq);
return Boolean(actionAttemptId && actionAttemptId === attemptId);
}

private findApprovalForExpectedRun(runId: string | undefined, approvalId: string) {
return this.ports.listApprovalRequests({
...(runId ? { runId } : {}),
complete: true,
}).find((approval) => approval.id === approvalId);
}

private listAllRunEvents(runId: string): readonly RunEventEnvelope[] {
const events: RunEventEnvelope[] = [];
let afterSeq = 0;
while (true) {
const page = this.ports.listEvents(runId, { afterSeq, limit: 1000 });
events.push(...page);
const latestSeq = page.at(-1)?.seq;
if (page.length < 1000 || latestSeq === undefined || latestSeq <= afterSeq) return events;
afterSeq = latestSeq;
}
}

createChannelWorkflowStep(input: CreateChannelWorkflowStepInput, now: string): ChannelWorkflowStep {
const workflow = this.getChannelWorkflow(input.workflowId);
if (!workflow) {
Expand Down Expand Up @@ -1484,6 +1654,22 @@ function sameDelegatedEvidenceRefs(
return JSON.stringify([...left].map(key).sort()) === JSON.stringify([...right].map(key).sort());
}

function isUnknownRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}

function latestRunAttemptIdAtOrBefore(
events: readonly RunEventEnvelope[],
throughSeq: number,
): string | undefined {
for (const event of [...events].sort((left, right) => right.seq - left.seq)) {
if (event.seq > throughSeq || event.type !== "run_created" || !isUnknownRecord(event.data)) continue;
const attemptId = event.data.attemptId;
if (typeof attemptId === "string" && attemptId.trim()) return attemptId;
}
return undefined;
}

function isWorkflowHandoffIntentStatusValue(value: unknown): value is WorkflowHandoffIntentStatus {
return value === "proposed" || value === "validated" || value === "accepted" || value === "declined" || value === "converted" || value === "failed";
}
Expand Down
Loading