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
40 changes: 30 additions & 10 deletions backend/src/development/developmentState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export interface DevelopmentStateResult {
workspace: DevelopmentWorkspaceState;
}

export interface DevelopmentStateOptions {
refreshDatabaseSnapshot?: boolean;
}

interface DevelopmentStateMarker {
formatVersion: 1;
owner: string;
Expand Down Expand Up @@ -250,6 +254,17 @@ function createDevelopmentDatabaseSnapshot(
}
chmodSync(stagingPath, 0o600);
scrubDevelopmentDatabase(stagingPath, shouldPreserveWebAuthnCredentials);
for (const suffix of ["-journal", "-shm", "-wal"]) {
const sidecarPath = `${targetPath}${suffix}`;
if (isPathPresentNoFollow(sidecarPath)) {
if (!isRealRegularFile(sidecarPath)) {
throw new Error(
`Development database sidecar must be a real regular file: ${sidecarPath}`
);
}
rmSync(sidecarPath, { force: true });
}
}
renameSync(stagingPath, targetPath);
} catch (error) {
rmSync(stagingPath, { force: true });
Expand Down Expand Up @@ -433,7 +448,8 @@ export function developmentSecretEncryptionKey(config: DevelopmentStackConfig):
* @returns Created or reuses isolated, ignored development state.
*/
export function prepareDevelopmentState(
config: DevelopmentStackConfig
config: DevelopmentStackConfig,
options: DevelopmentStateOptions = {}
): DevelopmentStateResult {
assertOrCreateStateOwnership(config);
ensurePrivateStateDirectory(config, config.openClawClientHome);
Expand All @@ -448,21 +464,25 @@ export function prepareDevelopmentState(
});

let database: DevelopmentStateResult["database"];
if (isPathPresentNoFollow(config.databasePath)) {
if (!isRealRegularFile(config.databasePath)) {
throw new Error("Development database must be a real regular file");
}
if (config.sourceWebAuthnRpId !== config.rpId) {
scrubDevelopmentDatabase(config.databasePath, false);
}
database = "reused";
} else if (config.databaseSource) {
const hasDatabase = isPathPresentNoFollow(config.databasePath);
if (hasDatabase && !isRealRegularFile(config.databasePath)) {
throw new Error("Development database must be a real regular file");
}
if (
config.databaseSource &&
(!hasDatabase || options.refreshDatabaseSnapshot === true)
) {
createDevelopmentDatabaseSnapshot(
config.databaseSource,
config.databasePath,
config.sourceWebAuthnRpId === config.rpId
);
database = "snapshot-created";
} else if (hasDatabase) {
if (config.sourceWebAuthnRpId !== config.rpId) {
scrubDevelopmentDatabase(config.databasePath, false);
}
database = "reused";
} else {
database = "created-empty";
}
Expand Down
4 changes: 3 additions & 1 deletion backend/src/services/pullRequestPreviews/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export function preparePreviewState(
environment,
config.dashboardRoot
);
prepareDevelopmentState(developmentConfig);
prepareDevelopmentState(developmentConfig, {
refreshDatabaseSnapshot: true,
});
return stateRoot;
}

Expand Down
55 changes: 55 additions & 0 deletions backend/test/developmentStack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,61 @@ describe("development stack", () => {
.get()
).toEqual({ value: "preserved" });
reusedSnapshot.close();

const productionDatabase = new Database(sourceDatabase);
productionDatabase
.prepare("UPDATE app_config SET value = ? WHERE key = 'theme'")
.run("fresh");
productionDatabase.close();
expect(
prepareDevelopmentState(config, { refreshDatabaseSnapshot: true })
).toEqual({
database: "snapshot-created",
releases: "reused",
workspace: "reused",
});
const refreshedSnapshot = new Database(config.databasePath, {
readonly: true,
});
expect(
refreshedSnapshot
.query(
"SELECT value FROM app_config WHERE key = 'development-marker'"
)
.get()
).toBeNull();
expect(
refreshedSnapshot
.query("SELECT value FROM app_config WHERE key = 'theme'")
.get()
).toEqual({ value: "fresh" });
refreshedSnapshot.close();

const staleSidecar = `${config.databasePath}-wal`;
writeFileSync(staleSidecar, "stale preview journal");
expect(
prepareDevelopmentState(config, { refreshDatabaseSnapshot: true })
.database
).toBe("snapshot-created");
expect(existsSync(staleSidecar)).toBe(false);

symlinkSync(sourceDatabase, staleSidecar);
expect(() =>
prepareDevelopmentState(config, { refreshDatabaseSnapshot: true })
).toThrow("Development database sidecar must be a real regular file");
rmSync(staleSidecar);

rmSync(config.databasePath);
symlinkSync(sourceDatabase, config.databasePath);
expect(() =>
prepareDevelopmentState(config, { refreshDatabaseSnapshot: true })
).toThrow("Development database must be a real regular file");
rmSync(config.databasePath);
expect(
prepareDevelopmentState(config, { refreshDatabaseSnapshot: true })
.database
).toBe("snapshot-created");

rmSync(sourceDatabase);
expect(prepareDevelopmentState(config)).toEqual({
database: "reused",
Expand Down
3 changes: 3 additions & 0 deletions backend/test/pullRequestPreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,9 @@ describe("managed pull request preview", () => {
);
expect(worktreeAddIndex).toBeGreaterThanOrEqual(0);
expect(prepareStateSpy).toHaveBeenCalledTimes(1);
expect(prepareStateSpy).toHaveBeenCalledWith(expect.any(Object), {
refreshDatabaseSnapshot: true,
});
expect(protectFromCancellation).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(
"http://127.0.0.1:5173/api/health/ready",
Expand Down
23 changes: 19 additions & 4 deletions frontend/src/components/features/chat/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";

import { CodeSyntaxHighlighter } from "../../../lib/syntaxHighlighter";
import { cn } from "../../../utils/cn";
import { CopyButton } from "../../ui/CopyButton";
import {
getPreCodeBlock,
isJsonLike,
Expand All @@ -24,8 +25,15 @@ function ChatCodeBlock({ code, language }: { code: string; language: string }) {
if (parsedJson) {
return (
<div className="my-1.5 max-w-full overflow-hidden rounded-lg border border-white/10 bg-black/25">
<div className="border-b border-white/10 px-2 py-0.5 text-[10px] tracking-wide text-primary-400 uppercase">
{JSON_LANGUAGES.has(language) ? language : "json"}
<div className="flex items-center justify-between gap-2 border-b border-white/10 px-2 py-0.5">
<span className="text-[10px] tracking-wide text-primary-400 uppercase">
{JSON_LANGUAGES.has(language) ? language : "json"}
</span>
<CopyButton
className="px-1.5 py-0 text-[10px]"
content={code}
label="Copy code"
/>
</div>
<div className="max-w-full overflow-x-auto p-2">
<ReactJsonView
Expand All @@ -49,8 +57,15 @@ function ChatCodeBlock({ code, language }: { code: string; language: string }) {

return (
<div className="my-1.5 max-w-full overflow-hidden rounded-lg border border-white/10 bg-black/25">
<div className="border-b border-white/10 px-2 py-0.5 text-[10px] tracking-wide text-primary-400 uppercase">
{language}
<div className="flex items-center justify-between gap-2 border-b border-white/10 px-2 py-0.5">
<span className="text-[10px] tracking-wide text-primary-400 uppercase">
{language}
</span>
<CopyButton
className="px-1.5 py-0 text-[10px]"
content={code}
label="Copy code"
/>
</div>
<CodeSyntaxHighlighter
language={normalizeSyntaxLanguage(language)}
Expand Down
37 changes: 7 additions & 30 deletions frontend/src/components/features/database/TopQueriesTable.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { createColumnHelper } from "@tanstack/react-table";
import { Copy } from "lucide-react";
import { useState } from "react";

import type { DatabaseOverviewResponse } from "../../../../../contracts/database";
import { Button } from "../../ui/Button";
import { Card } from "../../ui/Card";
import { CopyButton } from "../../ui/CopyButton";
import { EmptyState } from "../../ui/EmptyState";
import { Modal } from "../../ui/Modal";
import { DatabaseTableShell } from "./DatabaseTableShell";
Expand Down Expand Up @@ -57,7 +56,6 @@ export function TopQueriesTable({
const [selectedQuery, setSelectedQuery] = useState<
DatabaseOverviewResponse["topQueries"][number] | undefined
>();
const [copied, setCopied] = useState(false);

if (!enabled) {
return (
Expand All @@ -67,21 +65,6 @@ export function TopQueriesTable({
);
}

/**
* Responds to copy events.
* @param query Query value.
*/
const handleCopy = async (query: string) => {
try {
await navigator.clipboard.writeText(query);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (error_) {
setCopied(false);
console.error("Failed to copy query", error_);
}
};

return (
<>
<DatabaseTableShell
Expand Down Expand Up @@ -121,10 +104,7 @@ export function TopQueriesTable({

<Modal
isOpen={!!selectedQuery}
onClose={() => {
setSelectedQuery(undefined);
setCopied(false);
}}
onClose={() => setSelectedQuery(undefined)}
title="Query details"
size="3xl"
>
Expand All @@ -138,15 +118,12 @@ export function TopQueriesTable({
</div>

<div className="flex justify-stretch sm:justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => void handleCopy(selectedQuery.query)}
<CopyButton
className="w-full sm:w-auto"
>
<Copy className="size-4" />
{copied ? "Copied" : "Copy query"}
</Button>
content={selectedQuery.query}
label="Copy query"
variant="secondary"
/>
</div>

<pre className="max-h-[70vh] overflow-auto rounded-lg border border-primary-700 bg-primary-900/50 p-3 text-xs wrap-break-word whitespace-pre-wrap text-primary-100 sm:p-4 sm:text-sm">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ export function PullRequestActions({ context, pr }: PullRequestActionsProperties
{previewActions.blockedMessage}
</p>
) : undefined}
{previewActions.controls}
{canConfiguredReviewerApproveReview(pr) ? (
<Button
variant="secondary"
Expand All @@ -330,7 +331,6 @@ export function PullRequestActions({ context, pr }: PullRequestActionsProperties
{context.isUpdateBranchPending ? "Updating..." : "Update branch"}
</Button>
) : undefined}
{previewActions.controls}
<Button
variant="primary"
onClick={() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ExternalLink, MonitorPlay, Square } from "lucide-react";
import type { ReactNode } from "react";
import type { ComponentProps, ReactNode } from "react";

import type { PullRequestPreviewStatus } from "../../../../../contracts/delivery/previews";
import { messageFromError } from "../../../lib/errorMessage";
Expand All @@ -21,7 +21,7 @@ function previewVariant(status: PullRequestPreviewStatus["status"]) {
return "error" as const;
}
case "stopped": {
return "default" as const;
return "success" as const;
}
}
}
Expand Down Expand Up @@ -64,7 +64,7 @@ export function PullRequestDevelopmentCard({
const status = preview?.status ?? "stopped";
const hasPreview = preview?.number !== undefined;
const areControlsAvailable = preview?.controlsAvailable !== false;
let badgeVariant = previewVariant(status);
let badgeVariant: ComponentProps<typeof Badge>["variant"] = previewVariant(status);
let badgeLabel = previewLabel(status);
if (error) {
badgeVariant = "error";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ import {
indexPullRequestStackCandidates,
} from "./pullRequestStacks";

type MergePendingAction = Extract<
Exclude<PendingAction, undefined>,
{ type: "merge" | "merge-deploy" }
>;

function mergeProgressMessage(action: MergePendingAction): string {
const target = action.pr.stack
? `stack through PR #${action.pr.number}`
: `PR #${action.pr.number}`;
return action.type === "merge-deploy"
? `Merging ${target} and preparing deploy...`
: `Merging ${target}...`;
}

/**
* Owns Delivery data, mutations, confirmations, and derived action context.
* @returns Delivery page state, derived groups, and actions.
Expand Down Expand Up @@ -64,7 +78,9 @@ export function useDeliveryController() {
const [pendingAction, setPendingAction] = useState<PendingAction>();
const [lastResult, setLastResult] = useState<string | undefined>();
const [actionError, setActionError] = useState<string | undefined>();
const [actionProgress, setActionProgress] = useState<string | undefined>();
const isActionPending =
actionProgress !== undefined ||
approvePullRequest.isPending ||
approvePullRequestReview.isPending ||
createPullRequestStack.isPending ||
Expand Down Expand Up @@ -125,6 +141,8 @@ export function useDeliveryController() {
action.pr,
action.scope
);
setPendingAction(undefined);
setActionProgress(mergeProgressMessage(action));
const result = await approvePullRequest.mutateAsync({
expectedHeadSha,
expectedStackHeads,
Expand Down Expand Up @@ -158,6 +176,8 @@ export function useDeliveryController() {
action.pr,
action.scope
);
setPendingAction(undefined);
setActionProgress(mergeProgressMessage(action));
const result = await approvePullRequest.mutateAsync({
expectedHeadSha,
expectedStackHeads,
Expand Down Expand Up @@ -273,6 +293,8 @@ export function useDeliveryController() {
setPendingAction(undefined);
} catch (error_) {
setActionError(messageFromError(error_, "Action failed"));
} finally {
setActionProgress(undefined);
}
}

Expand Down Expand Up @@ -306,6 +328,7 @@ export function useDeliveryController() {

return {
actionError,
actionProgress,
confirmAction,
deployments,
deployBlockedReasonId,
Expand Down
Loading