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
18 changes: 12 additions & 6 deletions src/browser/components/AutomationModal/AutomationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ import {
workflowScheduleIntervalMinutesToMs,
} from "@/browser/utils/workflowScheduleIntervalMinutes";

// Fallback messages shown when the save/remove RPC fails without a specific
// error string. Deduplicated so the wording stays consistent across the
// pre-check, RPC-result, and thrown-error paths below.
const SAVE_AUTOMATION_ERROR_MESSAGE = "Failed to save automation.";
const REMOVE_AUTOMATION_ERROR_MESSAGE = "Failed to remove automation.";

interface AutomationModalProps {
open: boolean;
projectPath: string;
Expand Down Expand Up @@ -308,7 +314,7 @@ export function AutomationModal(props: AutomationModalProps) {
schedule: workspaceSchedule,
});
if (!result.success) {
setSaveError(result.error ?? "Failed to save automation.");
setSaveError(result.error ?? SAVE_AUTOMATION_ERROR_MESSAGE);
return false;
}
} else {
Expand All @@ -327,15 +333,15 @@ export function AutomationModal(props: AutomationModalProps) {
},
});
if (!result.success) {
setSaveError(result.error ?? "Failed to save automation.");
setSaveError(result.error ?? SAVE_AUTOMATION_ERROR_MESSAGE);
return false;
}
}

await refreshProjects();
return true;
} catch (error) {
setSaveError(getErrorMessage(error) || "Failed to save automation.");
setSaveError(getErrorMessage(error) || SAVE_AUTOMATION_ERROR_MESSAGE);
return false;
} finally {
setIsSaving(false);
Expand Down Expand Up @@ -388,21 +394,21 @@ export function AutomationModal(props: AutomationModalProps) {
scheduleId: props.projectWorkflowSchedule.id,
});
if (!result.success) {
throw new Error(result.error ?? "Failed to remove automation.");
throw new Error(result.error ?? REMOVE_AUTOMATION_ERROR_MESSAGE);
}
} else if (isLegacyWorkspaceSchedule) {
const result = await api.workspace.setWorkflowSchedule({
workspaceId: props.workspaceId,
schedule: null,
});
if (!result.success) {
throw new Error(result.error ?? "Failed to remove automation.");
throw new Error(result.error ?? REMOVE_AUTOMATION_ERROR_MESSAGE);
}
}
await refreshProjects();
props.onOpenChange(false);
} catch (error) {
setSaveError(getErrorMessage(error) || "Failed to remove automation.");
setSaveError(getErrorMessage(error) || REMOVE_AUTOMATION_ERROR_MESSAGE);
} finally {
setIsSaving(false);
}
Expand Down
6 changes: 1 addition & 5 deletions src/browser/features/Tools/WorkflowRunToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1077,10 +1077,6 @@ export const WorkflowRunToolCall: React.FC<WorkflowRunToolCallProps> = ({
resetKey: runId,
});

const toggleWorkflowExpanded = () => {
toggleExpanded();
};

const [actionError, setActionError] = useState<string | null>(null);
const [promotedDefinition, setPromotedDefinition] = useState<WorkflowDefinitionDescriptor | null>(
null
Expand Down Expand Up @@ -1433,7 +1429,7 @@ export const WorkflowRunToolCall: React.FC<WorkflowRunToolCallProps> = ({

return (
<ToolContainer expanded={expanded}>
<ToolHeader onClick={toggleWorkflowExpanded}>
<ToolHeader onClick={toggleExpanded}>
<ExpandIcon expanded={expanded}>â–¶</ExpandIcon>
<ToolIcon toolName={toolName} />
<WorkflowKindBadge />
Expand Down
24 changes: 16 additions & 8 deletions src/browser/hooks/useModelsFromSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,28 @@ const actualRoutingModule = { ...RoutingModule };
const actualAPIModule = { ...APIModule };
const actualPolicyContextModule = { ...PolicyContextModule };

// Specifiers mocked by this suite, shared by install/restore so the two helpers can never
// drift: a path that gets mocked but not restored would silently re-introduce the
// cross-suite leakage these helpers exist to prevent.
const PROVIDERS_CONFIG_MODULE = "@/browser/hooks/useProvidersConfig";
const ROUTING_MODULE = "@/browser/hooks/useRouting";
const API_MODULE = "@/browser/contexts/API";
const POLICY_CONTEXT_MODULE = "@/browser/contexts/PolicyContext";

async function installUseModelsModuleMocks() {
await mock.module("@/browser/hooks/useProvidersConfig", () => ({
await mock.module(PROVIDERS_CONFIG_MODULE, () => ({
...actualProvidersConfigModule,
useProvidersConfig: useProvidersConfigMock,
}));
await mock.module("@/browser/hooks/useRouting", () => ({
await mock.module(ROUTING_MODULE, () => ({
...actualRoutingModule,
useRouting: useRoutingMock,
}));
await mock.module("@/browser/contexts/API", () => ({
await mock.module(API_MODULE, () => ({
...actualAPIModule,
useAPI: () => ({ api: apiMock }),
}));
await mock.module("@/browser/contexts/PolicyContext", () => ({
await mock.module(POLICY_CONTEXT_MODULE, () => ({
...actualPolicyContextModule,
usePolicy: () => ({
status: { state: "disabled" as const },
Expand All @@ -112,10 +120,10 @@ async function installUseModelsModuleMocks() {
async function restoreUseModelsModuleMocks() {
// Bun's mock.module() has no disposer, and mock.restore() does not undo module mocks.
// Restore the real modules so this file cannot poison later tests in the same process.
await mock.module("@/browser/hooks/useProvidersConfig", () => actualProvidersConfigModule);
await mock.module("@/browser/hooks/useRouting", () => actualRoutingModule);
await mock.module("@/browser/contexts/API", () => actualAPIModule);
await mock.module("@/browser/contexts/PolicyContext", () => actualPolicyContextModule);
await mock.module(PROVIDERS_CONFIG_MODULE, () => actualProvidersConfigModule);
await mock.module(ROUTING_MODULE, () => actualRoutingModule);
await mock.module(API_MODULE, () => actualAPIModule);
await mock.module(POLICY_CONTEXT_MODULE, () => actualPolicyContextModule);
}

let cleanupDom: (() => void) | null = null;
Expand Down
7 changes: 5 additions & 2 deletions src/node/services/analytics/etl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import { getErrorMessage } from "@/common/utils/errors";
import { createDisplayUsage } from "@/common/utils/tokens/displayUsage";
import { log } from "@/node/services/log";
import { toUtcDateString } from "@/node/services/analytics/dateUtils";
import { CHAT_ARCHIVE_FILE_NAME } from "@/common/constants/paths";
import { CHAT_FILE_NAME, CHAT_ARCHIVE_FILE_NAME } from "@/common/constants/paths";

export const CHAT_FILE_NAME = "chat.jsonl";
// Re-export the canonical chat history filename (defined in constants/paths.ts)
// so existing analytics consumers (workspaceDiscovery, tests) can keep importing
// it from this module without duplicating the "chat.jsonl" literal.
export { CHAT_FILE_NAME };

/**
* Sealed pre-boundary history rotates from chat.jsonl into chat-archive.jsonl
Expand Down
21 changes: 14 additions & 7 deletions src/node/services/memoryConsolidationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,22 @@ function findNewestWorkspaceRecord(
);
}

/**
* Effective ordering timestamp for a harvest record: when it completed, or when
* it started if still pending. findNewestHarvestRecord and pruneHarvestRecords
* must rank records the same way, so both derive recency from this single key.
*/
function harvestRecordTime(record: MemoryHarvestRecord): number {
return record.completedAt ?? record.startedAt;
}

function findNewestHarvestRecord(
records: Record<string, MemoryHarvestRecord> | undefined
): MemoryHarvestRecord | null {
if (records === undefined) return null;
return Object.values(records).reduce<MemoryHarvestRecord | null>((latest, record) => {
const recordTime = record.completedAt ?? record.startedAt;
const latestTime = latest === null ? -1 : (latest.completedAt ?? latest.startedAt);
const recordTime = harvestRecordTime(record);
const latestTime = latest === null ? -1 : harvestRecordTime(latest);
return recordTime > latestTime ? record : latest;
}, null);
}
Expand Down Expand Up @@ -231,11 +240,9 @@ function parseHarvestRecords(value: unknown): Record<string, Record<string, Memo
}

function pruneHarvestRecords(records: Record<string, MemoryHarvestRecord>): void {
const ranked = Object.entries(records).sort(([, left], [, right]) => {
const leftTime = left.completedAt ?? left.startedAt;
const rightTime = right.completedAt ?? right.startedAt;
return rightTime - leftTime;
});
const ranked = Object.entries(records).sort(
([, left], [, right]) => harvestRecordTime(right) - harvestRecordTime(left)
);
for (const [boundaryKey] of ranked.slice(HARVEST_RECORD_RETENTION)) {
delete records[boundaryKey];
}
Expand Down
3 changes: 2 additions & 1 deletion src/node/services/workflows/WorkflowRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { validateJsonSchemaSubset } from "@/common/utils/jsonSchemaSubset";
import { removeCommonJsWorkflowMetadataDeclaration } from "./staticWorkflowMetadata";
import { WORKFLOW_RUNTIME_STDLIB_SOURCE } from "./workflowRuntimeSources.generated";
import type { IJSRuntime, IJSRuntimeFactory } from "@/node/services/ptc/runtime";
import { isWorkflowRunTaskId } from "@/node/services/tools/taskId";
import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex";
import { AsyncSemaphore } from "@/node/utils/concurrency/asyncSemaphore";
import type { ResolvedWorkflowAction, WorkflowActionRegistry } from "./WorkflowActionRegistry";
Expand Down Expand Up @@ -1001,7 +1002,7 @@ export class WorkflowRunner {
const run = await this.runStore.getRun(runId);
const driftedStep = run.steps.find(
(step) =>
step.stepId === spec.id && step.inputHash !== inputHash && step.taskId?.startsWith("wfr_")
step.stepId === spec.id && step.inputHash !== inputHash && isWorkflowRunTaskId(step.taskId)
);
if (driftedStep != null) {
throw new Error(
Expand Down
9 changes: 7 additions & 2 deletions src/node/services/workflows/workflowArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ interface TokenizeResult {

const RAW_INPUT_FIELD = "input";

// Shared by assertObjectSchema and propertySchemas so the two `properties`
// validators can't drift to differing messages for the same failure.
const ARGS_SCHEMA_PROPERTIES_ERROR_MESSAGE =
"Workflow metadata.argsSchema.properties must be an object";

export function normalizeWorkflowArgsForSource(
definitionSource: string,
rawArgs: unknown
Expand Down Expand Up @@ -123,14 +128,14 @@ function assertObjectSchema(schema: Record<string, unknown>): void {
throw new Error('Workflow metadata.argsSchema must have type "object"');
}
if (!isPlainObject(schema.properties)) {
throw new Error("Workflow metadata.argsSchema.properties must be an object");
throw new Error(ARGS_SCHEMA_PROPERTIES_ERROR_MESSAGE);
}
}

function propertySchemas(schema: Record<string, unknown>): ArgsPropertySchema[] {
const rawProperties = schema.properties;
if (!isPlainObject(rawProperties)) {
throw new Error("Workflow metadata.argsSchema.properties must be an object");
throw new Error(ARGS_SCHEMA_PROPERTIES_ERROR_MESSAGE);
}

return Object.entries(rawProperties).map(([name, rawProperty]) => {
Expand Down
Loading